Skip to content

Commit d4202e0

Browse files
committed
Moved leetcode-related code to a separate dir and added tests
1 parent 4bdbd05 commit d4202e0

File tree

10 files changed

+551
-297
lines changed

10 files changed

+551
-297
lines changed

.github/workflows/tests.yml

+2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ jobs:
1313
python-version: 3.9
1414
- name: Install requirements
1515
run: pip install -r requirements.txt
16+
- name: Install test requirements
17+
run: pip install -r test-requirements.txt
1618
- name: Install pytest
1719
run: pip install pytest
1820
- name: Run pytest

.github/workflows/type-check.yml

+2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ jobs:
1313
python-version: 3.9
1414
- name: Install requirements
1515
run: pip install -r requirements.txt
16+
- name: Install test requirements
17+
run: pip install -r test-requirements.txt
1618
- name: Install mypy
1719
run: pip install mypy
1820
- name: Run mypy

generate.py

+6-297
Original file line numberDiff line numberDiff line change
@@ -6,37 +6,20 @@
66

77
import argparse
88
import asyncio
9-
import functools
10-
import json
119
import logging
12-
import os
13-
import time
14-
from functools import lru_cache
15-
from typing import Any, Callable, Coroutine, Dict, Iterator, List, Tuple
16-
17-
import diskcache # type: ignore
10+
from typing import Any, Coroutine, List
1811

1912
# https://github.com/kerrickstaley/genanki
2013
import genanki # type: ignore
21-
22-
# https://github.com/prius/python-leetcode
23-
import leetcode.api.default_api # type: ignore
24-
import leetcode.api_client # type: ignore
25-
import leetcode.auth # type: ignore
26-
import leetcode.configuration # type: ignore
27-
import leetcode.models.graphql_query # type: ignore
28-
import leetcode.models.graphql_query_get_question_detail_variables # type: ignore
29-
import urllib3 # type: ignore
3014
from tqdm import tqdm # type: ignore
3115

16+
import leetcode_anki.helpers.leetcode
17+
3218
LEETCODE_ANKI_MODEL_ID = 4567610856
3319
LEETCODE_ANKI_DECK_ID = 8589798175
3420
OUTPUT_FILE = "leetcode.apkg"
35-
CACHE_DIR = "cache"
3621
ALLOWED_EXTENSIONS = {".py", ".go"}
3722

38-
leetcode_api_access_lock = asyncio.Lock()
39-
4023

4124
logging.getLogger().setLevel(logging.INFO)
4225

@@ -58,241 +41,6 @@ def parse_args() -> argparse.Namespace:
5841
return args
5942

6043

61-
def retry(times: int, exceptions: Tuple[Exception], delay: float) -> Callable:
62-
"""
63-
Retry Decorator
64-
Retries the wrapped function/method `times` times if the exceptions listed
65-
in `exceptions` are thrown
66-
"""
67-
68-
def decorator(func):
69-
@functools.wraps(func)
70-
async def wrapper(*args, **kwargs):
71-
for attempt in range(times - 1):
72-
try:
73-
return await func(*args, **kwargs)
74-
except exceptions:
75-
logging.exception(
76-
"Exception occured, try %s/%s", attempt + 1, times
77-
)
78-
time.sleep(delay)
79-
80-
logging.error("Last try")
81-
return await func(*args, **kwargs)
82-
83-
return wrapper
84-
85-
return decorator
86-
87-
88-
class LeetcodeData:
89-
"""
90-
Retrieves and caches the data for problems, acquired from the leetcode API.
91-
92-
This data can be later accessed using provided methods with corresponding
93-
names.
94-
"""
95-
96-
def __init__(self) -> None:
97-
"""
98-
Initialize leetcode API and disk cache for API responses
99-
"""
100-
self._api_instance = get_leetcode_api_client()
101-
102-
if not os.path.exists(CACHE_DIR):
103-
os.mkdir(CACHE_DIR)
104-
self._cache = diskcache.Cache(CACHE_DIR)
105-
106-
@retry(times=3, exceptions=(urllib3.exceptions.ProtocolError,), delay=5)
107-
async def _get_problem_data(self, problem_slug: str) -> Dict[str, str]:
108-
"""
109-
Get data about a specific problem (method output if cached to reduce
110-
the load on the leetcode API)
111-
"""
112-
if problem_slug in self._cache:
113-
return self._cache[problem_slug]
114-
115-
api_instance = self._api_instance
116-
117-
graphql_request = leetcode.models.graphql_query.GraphqlQuery(
118-
query="""
119-
query getQuestionDetail($titleSlug: String!) {
120-
question(titleSlug: $titleSlug) {
121-
freqBar
122-
questionId
123-
questionFrontendId
124-
boundTopicId
125-
title
126-
content
127-
translatedTitle
128-
translatedContent
129-
isPaidOnly
130-
difficulty
131-
likes
132-
dislikes
133-
isLiked
134-
similarQuestions
135-
contributors {
136-
username
137-
profileUrl
138-
avatarUrl
139-
__typename
140-
}
141-
langToValidPlayground
142-
topicTags {
143-
name
144-
slug
145-
translatedName
146-
__typename
147-
}
148-
companyTagStats
149-
codeSnippets {
150-
lang
151-
langSlug
152-
code
153-
__typename
154-
}
155-
stats
156-
hints
157-
solution {
158-
id
159-
canSeeDetail
160-
__typename
161-
}
162-
status
163-
sampleTestCase
164-
metaData
165-
judgerAvailable
166-
judgeType
167-
mysqlSchemas
168-
enableRunCode
169-
enableTestMode
170-
envInfo
171-
__typename
172-
}
173-
}
174-
""",
175-
variables=leetcode.models.graphql_query_get_question_detail_variables.GraphqlQueryGetQuestionDetailVariables( # noqa: E501
176-
title_slug=problem_slug
177-
),
178-
operation_name="getQuestionDetail",
179-
)
180-
181-
# Critical section. Don't allow more than one parallel request to
182-
# the Leetcode API
183-
async with leetcode_api_access_lock:
184-
time.sleep(2) # Leetcode has a rate limiter
185-
data = api_instance.graphql_post(body=graphql_request).data.question
186-
187-
# Save data in the cache
188-
self._cache[problem_slug] = data
189-
190-
return data
191-
192-
async def _get_description(self, problem_slug: str) -> str:
193-
"""
194-
Problem description
195-
"""
196-
data = await self._get_problem_data(problem_slug)
197-
return data.content or "No content"
198-
199-
async def _stats(self, problem_slug: str) -> Dict[str, str]:
200-
"""
201-
Various stats about problem. Such as number of accepted solutions, etc.
202-
"""
203-
data = await self._get_problem_data(problem_slug)
204-
return json.loads(data.stats)
205-
206-
async def submissions_total(self, problem_slug: str) -> int:
207-
"""
208-
Total number of submissions of the problem
209-
"""
210-
return int((await self._stats(problem_slug))["totalSubmissionRaw"])
211-
212-
async def submissions_accepted(self, problem_slug: str) -> int:
213-
"""
214-
Number of accepted submissions of the problem
215-
"""
216-
return int((await self._stats(problem_slug))["totalAcceptedRaw"])
217-
218-
async def description(self, problem_slug: str) -> str:
219-
"""
220-
Problem description
221-
"""
222-
return await self._get_description(problem_slug)
223-
224-
async def difficulty(self, problem_slug: str) -> str:
225-
"""
226-
Problem difficulty. Returns colored HTML version, so it can be used
227-
directly in Anki
228-
"""
229-
data = await self._get_problem_data(problem_slug)
230-
diff = data.difficulty
231-
232-
if diff == "Easy":
233-
return "<font color='green'>Easy</font>"
234-
235-
if diff == "Medium":
236-
return "<font color='orange'>Medium</font>"
237-
238-
if diff == "Hard":
239-
return "<font color='red'>Hard</font>"
240-
241-
raise ValueError(f"Incorrect difficulty: {diff}")
242-
243-
async def paid(self, problem_slug: str) -> str:
244-
"""
245-
Problem's "available for paid subsribers" status
246-
"""
247-
data = await self._get_problem_data(problem_slug)
248-
return data.is_paid_only
249-
250-
async def problem_id(self, problem_slug: str) -> str:
251-
"""
252-
Numerical id of the problem
253-
"""
254-
data = await self._get_problem_data(problem_slug)
255-
return data.question_frontend_id
256-
257-
async def likes(self, problem_slug: str) -> int:
258-
"""
259-
Number of likes for the problem
260-
"""
261-
data = await self._get_problem_data(problem_slug)
262-
likes = data.likes
263-
264-
if not isinstance(likes, int):
265-
raise ValueError(f"Likes should be int: {likes}")
266-
267-
return likes
268-
269-
async def dislikes(self, problem_slug: str) -> int:
270-
"""
271-
Number of dislikes for the problem
272-
"""
273-
data = await self._get_problem_data(problem_slug)
274-
dislikes = data.dislikes
275-
276-
if not isinstance(dislikes, int):
277-
raise ValueError(f"Dislikes should be int: {dislikes}")
278-
279-
return dislikes
280-
281-
async def tags(self, problem_slug: str) -> List[str]:
282-
"""
283-
List of the tags for this problem (string slugs)
284-
"""
285-
data = await self._get_problem_data(problem_slug)
286-
return list(map(lambda x: x.slug, data.topic_tags))
287-
288-
async def freq_bar(self, problem_slug: str) -> float:
289-
"""
290-
Returns percentage for frequency bar
291-
"""
292-
data = await self._get_problem_data(problem_slug)
293-
return data.freq_bar or 0
294-
295-
29644
class LeetcodeNote(genanki.Note):
29745
"""
29846
Extended base class for the Anki note, that correctly sets the unique
@@ -305,47 +53,8 @@ def guid(self):
30553
return genanki.guid_for(self.fields[0])
30654

30755

308-
@lru_cache(None)
309-
def get_leetcode_api_client() -> leetcode.api.default_api.DefaultApi:
310-
"""
311-
Leetcode API instance constructor.
312-
313-
This is a singleton, because we don't need to create a separate client
314-
each time
315-
"""
316-
configuration = leetcode.configuration.Configuration()
317-
318-
session_id = os.environ["LEETCODE_SESSION_ID"]
319-
csrf_token = leetcode.auth.get_csrf_cookie(session_id)
320-
321-
configuration.api_key["x-csrftoken"] = csrf_token
322-
configuration.api_key["csrftoken"] = csrf_token
323-
configuration.api_key["LEETCODE_SESSION"] = session_id
324-
configuration.api_key["Referer"] = "https://leetcode.com"
325-
configuration.debug = False
326-
api_instance = leetcode.api.default_api.DefaultApi(
327-
leetcode.api_client.ApiClient(configuration)
328-
)
329-
330-
return api_instance
331-
332-
333-
def get_leetcode_task_handles() -> Iterator[Tuple[str, str, str]]:
334-
"""
335-
Get task handles for all the leetcode problems.
336-
"""
337-
api_instance = get_leetcode_api_client()
338-
339-
for topic in ["algorithms", "database", "shell", "concurrency"]:
340-
api_response = api_instance.api_problems_topic_get(topic=topic)
341-
for stat_status_pair in api_response.stat_status_pairs:
342-
stat = stat_status_pair.stat
343-
344-
yield (topic, stat.question__title, stat.question__title_slug)
345-
346-
34756
async def generate_anki_note(
348-
leetcode_data: LeetcodeData,
57+
leetcode_data: leetcode_anki.helpers.leetcode.LeetcodeData,
34958
leetcode_model: genanki.Model,
35059
leetcode_task_handle: str,
35160
leetcode_task_title: str,
@@ -449,12 +158,12 @@ async def generate(start: int, stop: int) -> None:
449158
],
450159
)
451160
leetcode_deck = genanki.Deck(LEETCODE_ANKI_DECK_ID, "leetcode")
452-
leetcode_data = LeetcodeData()
161+
leetcode_data = leetcode_anki.helpers.leetcode.LeetcodeData()
453162

454163
note_generators: List[Coroutine[Any, Any, LeetcodeNote]] = []
455164

456165
for topic, leetcode_task_title, leetcode_task_handle in list(
457-
get_leetcode_task_handles()
166+
leetcode_anki.helpers.leetcode.get_leetcode_task_handles()
458167
)[start:stop]:
459168
note_generators.append(
460169
generate_anki_note(

leetcode_anki/__init__.py

Whitespace-only changes.

leetcode_anki/helpers/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)