From 0332d55c382b6b7d73b580893143be914ab60ccb Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Tue, 26 Oct 2021 15:54:32 +0100 Subject: [PATCH 1/2] Added pyre configuration --- .pyre_configuration | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .pyre_configuration diff --git a/.pyre_configuration b/.pyre_configuration new file mode 100644 index 0000000..9573b80 --- /dev/null +++ b/.pyre_configuration @@ -0,0 +1,5 @@ +{ + "source_directories": [ + "." + ] +} From 643efda71c6917c4cb1588fb762e0ff43e87bdc6 Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Tue, 26 Oct 2021 21:42:36 +0100 Subject: [PATCH 2/2] Fetching problems in batches using new leetcode api --- README.md | 2 - generate.py | 20 +- leetcode_anki/helpers/leetcode.py | 241 +++++++++++++++++------ requirements.txt | 3 +- test/helpers/test_leetcode.py | 306 ++++++++++++++++++------------ 5 files changed, 372 insertions(+), 200 deletions(-) diff --git a/README.md b/README.md index 4b2cd23..5feb572 100644 --- a/README.md +++ b/README.md @@ -37,5 +37,3 @@ make generate ``` You'll get `leetcode.apkg` file, which you can import directly to your anki app. - -There also will be a `cache` directory created for the cached data about the problems. If you want to fetch more up to date information about the existing problems, delete this dir. Just keep in mind, it'll take a while to re-download the data about all the problems. diff --git a/generate.py b/generate.py index 240c82b..5ebc6ff 100755 --- a/generate.py +++ b/generate.py @@ -57,8 +57,6 @@ async def generate_anki_note( leetcode_data: leetcode_anki.helpers.leetcode.LeetcodeData, leetcode_model: genanki.Model, leetcode_task_handle: str, - leetcode_task_title: str, - topic: str, ) -> LeetcodeNote: """ Generate a single Anki flashcard @@ -68,8 +66,8 @@ async def generate_anki_note( fields=[ leetcode_task_handle, str(await leetcode_data.problem_id(leetcode_task_handle)), - leetcode_task_title, - topic, + str(await leetcode_data.title(leetcode_task_handle)), + str(await leetcode_data.category(leetcode_task_handle)), await leetcode_data.description(leetcode_task_handle), await leetcode_data.difficulty(leetcode_task_handle), "yes" if await leetcode_data.paid(leetcode_task_handle) else "no", @@ -158,24 +156,24 @@ async def generate(start: int, stop: int) -> None: ], ) leetcode_deck = genanki.Deck(LEETCODE_ANKI_DECK_ID, "leetcode") - leetcode_data = leetcode_anki.helpers.leetcode.LeetcodeData() + + leetcode_data = leetcode_anki.helpers.leetcode.LeetcodeData(start, stop) note_generators: List[Coroutine[Any, Any, LeetcodeNote]] = [] - for topic, leetcode_task_title, leetcode_task_handle in list( - leetcode_anki.helpers.leetcode.get_leetcode_task_handles() - )[start:stop]: + task_handles = await leetcode_data.all_problems_handles() + + logging.info("Generating flashcards") + for leetcode_task_handle in task_handles: note_generators.append( generate_anki_note( leetcode_data, leetcode_model, leetcode_task_handle, - leetcode_task_title, - topic, ) ) - for leetcode_note in tqdm(note_generators): + for leetcode_note in tqdm(note_generators, unit="flashcard"): leetcode_deck.add_note(await leetcode_note) genanki.Package(leetcode_deck).write_to_file(OUTPUT_FILE) diff --git a/leetcode_anki/helpers/leetcode.py b/leetcode_anki/helpers/leetcode.py index a416f98..3a5007b 100644 --- a/leetcode_anki/helpers/leetcode.py +++ b/leetcode_anki/helpers/leetcode.py @@ -1,13 +1,11 @@ -import asyncio import functools import json import logging +import math import os import time -from functools import lru_cache -from typing import Callable, Dict, Iterator, List, Tuple, Type - -import diskcache # type: ignore +from functools import cached_property +from typing import Callable, Dict, List, Tuple, Type # https://github.com/prius/python-leetcode import leetcode.api.default_api # type: ignore @@ -16,15 +14,15 @@ import leetcode.configuration # type: ignore import leetcode.models.graphql_query # type: ignore import leetcode.models.graphql_query_get_question_detail_variables # type: ignore +import leetcode.models.graphql_query_problemset_question_list_variables # type: ignore +import leetcode.models.graphql_query_problemset_question_list_variables_filter_input # type: ignore +import leetcode.models.graphql_question_detail # type: ignore import urllib3 # type: ignore +from tqdm import tqdm # type: ignore CACHE_DIR = "cache" -leetcode_api_access_lock = asyncio.Lock() - - -@lru_cache(None) def _get_leetcode_api_client() -> leetcode.api.default_api.DefaultApi: """ Leetcode API instance constructor. @@ -32,6 +30,7 @@ def _get_leetcode_api_client() -> leetcode.api.default_api.DefaultApi: This is a singleton, because we don't need to create a separate client each time """ + configuration = leetcode.configuration.Configuration() session_id = os.environ["LEETCODE_SESSION_ID"] @@ -49,20 +48,6 @@ def _get_leetcode_api_client() -> leetcode.api.default_api.DefaultApi: return api_instance -def get_leetcode_task_handles() -> Iterator[Tuple[str, str, str]]: - """ - Get task handles for all the leetcode problems. - """ - api_instance = _get_leetcode_api_client() - - for topic in ["algorithms", "database", "shell", "concurrency"]: - api_response = api_instance.api_problems_topic_get(topic=topic) - for stat_status_pair in api_response.stat_status_pairs: - stat = stat_status_pair.stat - - yield (topic, stat.question__title, stat.question__title_slug) - - def retry(times: int, exceptions: Tuple[Type[Exception]], delay: float) -> Callable: """ Retry Decorator @@ -72,10 +57,10 @@ def retry(times: int, exceptions: Tuple[Type[Exception]], delay: float) -> Calla def decorator(func): @functools.wraps(func) - async def wrapper(*args, **kwargs): + def wrapper(*args, **kwargs): for attempt in range(times - 1): try: - return await func(*args, **kwargs) + return func(*args, **kwargs) except exceptions: logging.exception( "Exception occured, try %s/%s", attempt + 1, times @@ -83,7 +68,7 @@ async def wrapper(*args, **kwargs): time.sleep(delay) logging.error("Last try") - return await func(*args, **kwargs) + return func(*args, **kwargs) return wrapper @@ -98,44 +83,105 @@ class LeetcodeData: names. """ - def __init__(self) -> None: + def __init__(self, start: int, stop: int) -> None: """ Initialize leetcode API and disk cache for API responses """ - self._api_instance = _get_leetcode_api_client() + if start < 0: + raise ValueError(f"Start must be non-negative: {start}") - if not os.path.exists(CACHE_DIR): - os.mkdir(CACHE_DIR) - self._cache = diskcache.Cache(CACHE_DIR) + if stop < 0: + raise ValueError(f"Stop must be non-negative: {start}") - @retry(times=3, exceptions=(urllib3.exceptions.ProtocolError,), delay=5) - async def _get_problem_data(self, problem_slug: str) -> Dict[str, str]: + if start > stop: + raise ValueError(f"Start (){start}) must be not greater than stop ({stop})") + + self._start = start + self._stop = stop + + @cached_property + def _api_instance(self) -> leetcode.api.default_api.DefaultApi: + return _get_leetcode_api_client() + + @cached_property + def _cache( + self, + ) -> Dict[str, leetcode.models.graphql_question_detail.GraphqlQuestionDetail]: """ - Get data about a specific problem (method output if cached to reduce - the load on the leetcode API) + Cached method to return dict (problem_slug -> question details) """ - if problem_slug in self._cache: - return self._cache[problem_slug] + problems = self._get_problems_data() + return {problem.title_slug: problem for problem in problems} + @retry(times=3, exceptions=(urllib3.exceptions.ProtocolError,), delay=5) + def _get_problems_count(self) -> int: api_instance = self._api_instance graphql_request = leetcode.models.graphql_query.GraphqlQuery( query=""" - query getQuestionDetail($titleSlug: String!) { - question(titleSlug: $titleSlug) { - freqBar + query problemsetQuestionList($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) { + problemsetQuestionList: questionList( + categorySlug: $categorySlug + limit: $limit + skip: $skip + filters: $filters + ) { + totalNum + } + } + """, + variables=leetcode.models.graphql_query_problemset_question_list_variables.GraphqlQueryProblemsetQuestionListVariables( + category_slug="", + limit=1, + skip=0, + filters=leetcode.models.graphql_query_problemset_question_list_variables_filter_input.GraphqlQueryProblemsetQuestionListVariablesFilterInput( + tags=[], + # difficulty="MEDIUM", + # status="NOT_STARTED", + # list_id="7p5x763", # Top Amazon Questions + # premium_only=False, + ), + ), + operation_name="problemsetQuestionList", + ) + + time.sleep(2) # Leetcode has a rate limiter + data = api_instance.graphql_post(body=graphql_request).data + + return data.problemset_question_list.total_num or 0 + + @retry(times=3, exceptions=(urllib3.exceptions.ProtocolError,), delay=5) + def _get_problems_data_page( + self, offset: int, page_size: int, page: int + ) -> List[leetcode.models.graphql_question_detail.GraphqlQuestionDetail]: + api_instance = self._api_instance + graphql_request = leetcode.models.graphql_query.GraphqlQuery( + query=""" + query problemsetQuestionList($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) { + problemsetQuestionList: questionList( + categorySlug: $categorySlug + limit: $limit + skip: $skip + filters: $filters + ) { + total: totalNum + questions: data { questionId questionFrontendId boundTopicId title + titleSlug + categoryTitle + frequency + freqBar content translatedTitle - translatedContent isPaidOnly difficulty likes dislikes isLiked + isFavor similarQuestions contributors { username @@ -158,54 +204,109 @@ async def _get_problem_data(self, problem_slug: str) -> Dict[str, str]: __typename } stats + acRate + codeDefinition hints solution { id canSeeDetail __typename } + hasSolution + hasVideoSolution status sampleTestCase + enableRunCode metaData + translatedContent judgerAvailable judgeType mysqlSchemas - enableRunCode enableTestMode envInfo __typename - } } + } + } """, - variables=leetcode.models.graphql_query_get_question_detail_variables.GraphqlQueryGetQuestionDetailVariables( # noqa: E501 - title_slug=problem_slug + variables=leetcode.models.graphql_query_problemset_question_list_variables.GraphqlQueryProblemsetQuestionListVariables( + category_slug="", + limit=page_size, + skip=offset + page * page_size, + filters=leetcode.models.graphql_query_problemset_question_list_variables_filter_input.GraphqlQueryProblemsetQuestionListVariablesFilterInput(), ), - operation_name="getQuestionDetail", + operation_name="problemsetQuestionList", ) - # Critical section. Don't allow more than one parallel request to - # the Leetcode API - async with leetcode_api_access_lock: - time.sleep(2) # Leetcode has a rate limiter - data = api_instance.graphql_post(body=graphql_request).data.question - - # Save data in the cache - self._cache[problem_slug] = data + time.sleep(2) # Leetcode has a rate limiter + data = api_instance.graphql_post( + body=graphql_request + ).data.problemset_question_list.questions return data + def _get_problems_data( + self, + ) -> List[leetcode.models.graphql_question_detail.GraphqlQuestionDetail]: + problem_count = self._get_problems_count() + + if self._start > problem_count: + raise ValueError( + "Start ({self._start}) is greater than problems count ({problem_count})" + ) + + start = self._start + stop = min(self._stop, problem_count) + + page_size = min(50, stop - start + 1) + + problems: List[ + leetcode.models.graphql_question_detail.GraphqlQuestionDetail + ] = [] + + logging.info(f"Fetching {stop - start + 1} problems {page_size} per page") + + for page in tqdm( + range(math.ceil((stop - start + 1) / page_size)), + unit="problem", + unit_scale=page_size, + ): + data = self._get_problems_data_page(start, page_size, page) + problems.extend(data) + + return problems + + async def all_problems_handles(self) -> List[str]: + """ + Get all problem handles known. + + Example: ["two-sum", "three-sum"] + """ + return list(self._cache.keys()) + + def _get_problem_data( + self, problem_slug: str + ) -> leetcode.models.graphql_question_detail.GraphqlQuestionDetail: + """ + TODO: Legacy method. Needed in the old architecture. Can be replaced + with direct cache calls later. + """ + cache = self._cache + if problem_slug in cache: + return cache[problem_slug] + async def _get_description(self, problem_slug: str) -> str: """ Problem description """ - data = await self._get_problem_data(problem_slug) + data = self._get_problem_data(problem_slug) return data.content or "No content" async def _stats(self, problem_slug: str) -> Dict[str, str]: """ Various stats about problem. Such as number of accepted solutions, etc. """ - data = await self._get_problem_data(problem_slug) + data = self._get_problem_data(problem_slug) return json.loads(data.stats) async def submissions_total(self, problem_slug: str) -> int: @@ -231,7 +332,7 @@ async def difficulty(self, problem_slug: str) -> str: Problem difficulty. Returns colored HTML version, so it can be used directly in Anki """ - data = await self._get_problem_data(problem_slug) + data = self._get_problem_data(problem_slug) diff = data.difficulty if diff == "Easy": @@ -249,21 +350,21 @@ async def paid(self, problem_slug: str) -> str: """ Problem's "available for paid subsribers" status """ - data = await self._get_problem_data(problem_slug) + data = self._get_problem_data(problem_slug) return data.is_paid_only async def problem_id(self, problem_slug: str) -> str: """ Numerical id of the problem """ - data = await self._get_problem_data(problem_slug) + data = self._get_problem_data(problem_slug) return data.question_frontend_id async def likes(self, problem_slug: str) -> int: """ Number of likes for the problem """ - data = await self._get_problem_data(problem_slug) + data = self._get_problem_data(problem_slug) likes = data.likes if not isinstance(likes, int): @@ -275,7 +376,7 @@ async def dislikes(self, problem_slug: str) -> int: """ Number of dislikes for the problem """ - data = await self._get_problem_data(problem_slug) + data = self._get_problem_data(problem_slug) dislikes = data.dislikes if not isinstance(dislikes, int): @@ -287,12 +388,26 @@ async def tags(self, problem_slug: str) -> List[str]: """ List of the tags for this problem (string slugs) """ - data = await self._get_problem_data(problem_slug) + data = self._get_problem_data(problem_slug) return list(map(lambda x: x.slug, data.topic_tags)) async def freq_bar(self, problem_slug: str) -> float: """ Returns percentage for frequency bar """ - data = await self._get_problem_data(problem_slug) + data = self._get_problem_data(problem_slug) return data.freq_bar or 0 + + async def title(self, problem_slug: str) -> float: + """ + Returns problem title + """ + data = self._get_problem_data(problem_slug) + return data.title + + async def category(self, problem_slug: str) -> float: + """ + Returns problem category title + """ + data = self._get_problem_data(problem_slug) + return data.category_title diff --git a/requirements.txt b/requirements.txt index b32aa76..951466f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ -python-leetcode==1.1.0 +python-leetcode==1.2.1 setuptools==57.5.0 -diskcache genanki tqdm diff --git a/test/helpers/test_leetcode.py b/test/helpers/test_leetcode.py index 40b9e34..dff81b4 100644 --- a/test/helpers/test_leetcode.py +++ b/test/helpers/test_leetcode.py @@ -1,8 +1,8 @@ -import sys -from typing import Optional +from typing import Dict, List, Optional from unittest import mock import leetcode.models.graphql_data # type: ignore +import leetcode.models.graphql_problemset_question_list # type: ignore import leetcode.models.graphql_question_contributor # type: ignore import leetcode.models.graphql_question_detail # type: ignore import leetcode.models.graphql_question_solution # type: ignore @@ -15,6 +15,66 @@ import leetcode_anki.helpers.leetcode +QUESTION_DETAIL = leetcode.models.graphql_question_detail.GraphqlQuestionDetail( + freq_bar=1.1, + question_id="1", + question_frontend_id="1", + bound_topic_id=1, + title="test title", + title_slug="test", + content="test content", + translated_title="test", + translated_content="test translated content", + is_paid_only=False, + difficulty="Hard", + likes=1, + dislikes=1, + is_liked=False, + similar_questions="{}", + contributors=[ + leetcode.models.graphql_question_contributor.GraphqlQuestionContributor( + username="testcontributor", + profile_url="test://profile/url", + avatar_url="test://avatar/url", + ), + ], + lang_to_valid_playground="{}", + topic_tags=[ + leetcode.models.graphql_question_topic_tag.GraphqlQuestionTopicTag( + name="test tag", + slug="test-tag", + translated_name="translated test tag", + typename="test type name", + ) + ], + company_tag_stats="{}", + code_snippets="{}", + stats='{"totalSubmissionRaw": 1, "totalAcceptedRaw": 1}', + hints=["test hint 1", "test hint 2"], + solution=[ + leetcode.models.graphql_question_solution.GraphqlQuestionSolution( + id=1, + can_see_detail=False, + typename="test type name", + ), + ], + status="ac", + sample_test_case="test case", + meta_data="{}", + judger_available=False, + judge_type="large", + mysql_schemas="test schema", + enable_run_code=False, + enable_test_mode=False, + env_info="{}", +) + + +def dummy_return_question_detail_dict( + question_detail: leetcode.models.graphql_question_detail.GraphqlQuestionDetail, +) -> Dict[str, leetcode.models.graphql_question_detail.GraphqlQuestionDetail]: + return {"test": question_detail} + @mock.patch("os.environ", mock.MagicMock(return_value={"LEETCODE_SESSION_ID": "test"})) @mock.patch("leetcode.auth", mock.MagicMock()) @@ -23,49 +83,6 @@ class TestLeetcode: async def test_get_leetcode_api_client(self) -> None: assert leetcode_anki.helpers.leetcode._get_leetcode_api_client() - @pytest.mark.asyncio - @mock.patch("leetcode_anki.helpers.leetcode._get_leetcode_api_client") - async def test_get_leetcode_task_handles(self, api_client: mock.Mock) -> None: - problems = leetcode.models.problems.Problems( - user_name="test", - num_solved=1, - num_total=1, - ac_easy=1, - ac_medium=1, - ac_hard=1, - frequency_high=1, - frequency_mid=1, - category_slug="test", - stat_status_pairs=[ - leetcode.models.stat_status_pair.StatStatusPair( - stat=leetcode.models.stat.Stat( - question_id=1, - question__hide=False, - question__title="Test 1", - question__title_slug="test1", - is_new_question=False, - frontend_question_id=1, - total_acs=1, - total_submitted=1, - ), - difficulty="easy", - is_favor=False, - status="ac", - paid_only=False, - frequency=0.0, - progress=1, - ), - ], - ) - api_client.return_value.api_problems_topic_get.return_value = problems - - assert list(leetcode_anki.helpers.leetcode.get_leetcode_task_handles()) == [ - ("algorithms", "Test 1", "test1"), - ("database", "Test 1", "test1"), - ("shell", "Test 1", "test1"), - ("concurrency", "Test 1", "test1"), - ] - @pytest.mark.asyncio async def test_retry(self) -> None: decorator = leetcode_anki.helpers.leetcode.retry( @@ -84,6 +101,7 @@ async def test() -> str: assert func.call_count == 3 +@mock.patch("leetcode_anki.helpers.leetcode._get_leetcode_api_client", mock.Mock()) class TestLeetcodeData: _question_detail_singleton: Optional[ leetcode.models.graphql_question_detail.GraphqlQuestionDetail @@ -112,131 +130,175 @@ def _leetcode_data(self) -> leetcode_anki.helpers.leetcode.LeetcodeData: return leetcode_data - @mock.patch("diskcache.Cache", mock.Mock(side_effect=lambda _: {})) - @mock.patch("os.path.exists", mock.Mock(return_value=True)) - @mock.patch("leetcode_anki.helpers.leetcode._get_leetcode_api_client") - def setup(self, leetcode_api: leetcode.api.default_api.DefaultApi) -> None: - self._question_detail_singleton = leetcode.models.graphql_question_detail.GraphqlQuestionDetail( - freq_bar=1.1, - question_id="1", - question_frontend_id="1", - bound_topic_id=1, - title="test title", - content="test content", - translated_title="test", - translated_content="test translated content", - is_paid_only=False, - difficulty="Hard", - likes=1, - dislikes=1, - is_liked=False, - similar_questions="{}", - contributors=[ - leetcode.models.graphql_question_contributor.GraphqlQuestionContributor( - username="testcontributor", - profile_url="test://profile/url", - avatar_url="test://avatar/url", - ), - ], - lang_to_valid_playground="{}", - topic_tags=[ - leetcode.models.graphql_question_topic_tag.GraphqlQuestionTopicTag( - name="test tag", - slug="test-tag", - translated_name="translated test tag", - typename="test type name", - ) - ], - company_tag_stats="{}", - code_snippets="{}", - stats='{"totalSubmissionRaw": 1, "totalAcceptedRaw": 1}', - hints=["test hint 1", "test hint 2"], - solution=[ - leetcode.models.graphql_question_solution.GraphqlQuestionSolution( - id=1, - can_see_detail=False, - typename="test type name", - ), - ], - status="ac", - sample_test_case="test case", - meta_data="{}", - judger_available=False, - judge_type="large", - mysql_schemas="test schema", - enable_run_code=False, - enable_test_mode=False, - env_info="{}", + def setup(self) -> None: + self._question_detail_singleton = QUESTION_DETAIL + self._leetcode_data_singleton = leetcode_anki.helpers.leetcode.LeetcodeData( + 0, 10000 ) - self._leetcode_data_singleton = leetcode_anki.helpers.leetcode.LeetcodeData() - def test_init(self) -> None: - self._leetcode_data._cache["test"] = self._question_details + @pytest.mark.asyncio + @mock.patch( + "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", + mock.Mock(return_value=[QUESTION_DETAIL]), + ) + async def test_init(self) -> None: + self._leetcode_data._cache["test"] = QUESTION_DETAIL @pytest.mark.asyncio + @mock.patch( + "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", + mock.Mock(return_value=[QUESTION_DETAIL]), + ) async def test_get_description(self) -> None: - self._leetcode_data._cache["test"] = self._question_details + self._leetcode_data._cache["test"] = QUESTION_DETAIL assert (await self._leetcode_data.description("test")) == "test content" @pytest.mark.asyncio + @mock.patch( + "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", + mock.Mock(return_value=[QUESTION_DETAIL]), + ) async def test_submissions(self) -> None: - self._leetcode_data._cache["test"] = self._question_details + self._leetcode_data._cache["test"] = QUESTION_DETAIL + assert (await self._leetcode_data.description("test")) == "test content" assert (await self._leetcode_data.submissions_total("test")) == 1 assert (await self._leetcode_data.submissions_accepted("test")) == 1 @pytest.mark.asyncio - async def test_difficulty(self) -> None: - self._leetcode_data._cache["test"] = self._question_details - - self._leetcode_data._cache["test"].difficulty = "Easy" + @mock.patch( + "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", + mock.Mock(return_value=[QUESTION_DETAIL]), + ) + async def test_difficulty_easy(self) -> None: + self._leetcode_data._cache["test"] = QUESTION_DETAIL + + QUESTION_DETAIL.difficulty = "Easy" assert "Easy" in (await self._leetcode_data.difficulty("test")) - self._leetcode_data._cache["test"].difficulty = "Medium" + @pytest.mark.asyncio + @mock.patch( + "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", + mock.Mock(return_value=[QUESTION_DETAIL]), + ) + async def test_difficulty_medium(self) -> None: + self._leetcode_data._cache["test"] = QUESTION_DETAIL + + QUESTION_DETAIL.difficulty = "Medium" assert "Medium" in (await self._leetcode_data.difficulty("test")) - self._leetcode_data._cache["test"].difficulty = "Hard" + @pytest.mark.asyncio + @mock.patch( + "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", + mock.Mock(return_value=[QUESTION_DETAIL]), + ) + async def test_difficulty_hard(self) -> None: + self._leetcode_data._cache["test"] = QUESTION_DETAIL + + QUESTION_DETAIL.difficulty = "Hard" assert "Hard" in (await self._leetcode_data.difficulty("test")) @pytest.mark.asyncio + @mock.patch( + "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", + mock.Mock(return_value=[QUESTION_DETAIL]), + ) async def test_paid(self) -> None: - self._leetcode_data._cache["test"] = self._question_details + self._leetcode_data._cache["test"] = QUESTION_DETAIL assert (await self._leetcode_data.paid("test")) is False @pytest.mark.asyncio + @mock.patch( + "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", + mock.Mock(return_value=[QUESTION_DETAIL]), + ) async def test_problem_id(self) -> None: - self._leetcode_data._cache["test"] = self._question_details + self._leetcode_data._cache["test"] = QUESTION_DETAIL assert (await self._leetcode_data.problem_id("test")) == "1" @pytest.mark.asyncio + @mock.patch( + "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", + mock.Mock(return_value=[QUESTION_DETAIL]), + ) async def test_likes(self) -> None: - self._leetcode_data._cache["test"] = self._question_details + self._leetcode_data._cache["test"] = QUESTION_DETAIL assert (await self._leetcode_data.likes("test")) == 1 + + @pytest.mark.asyncio + @mock.patch( + "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", + mock.Mock(return_value=[QUESTION_DETAIL]), + ) + async def test_dislikes(self) -> None: + self._leetcode_data._cache["test"] = QUESTION_DETAIL + assert (await self._leetcode_data.dislikes("test")) == 1 @pytest.mark.asyncio + @mock.patch( + "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", + mock.Mock(return_value=[QUESTION_DETAIL]), + ) async def test_tags(self) -> None: - self._leetcode_data._cache["test"] = self._question_details + self._leetcode_data._cache["test"] = QUESTION_DETAIL assert (await self._leetcode_data.tags("test")) == ["test-tag"] @pytest.mark.asyncio + @mock.patch( + "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", + mock.Mock(return_value=[QUESTION_DETAIL]), + ) async def test_freq_bar(self) -> None: - self._leetcode_data._cache["test"] = self._question_details + self._leetcode_data._cache["test"] = QUESTION_DETAIL assert (await self._leetcode_data.freq_bar("test")) == 1.1 - @mock.patch("time.sleep", mock.Mock()) @pytest.mark.asyncio + @mock.patch( + "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", + mock.Mock(return_value=[QUESTION_DETAIL]), + ) async def test_get_problem_data(self) -> None: - data = leetcode.models.graphql_data.GraphqlData(question=self._question_details) + assert self._leetcode_data._cache["test"] == QUESTION_DETAIL + + @mock.patch("time.sleep", mock.Mock()) + @pytest.mark.asyncio + async def test_get_problems_data_page(self) -> None: + data = leetcode.models.graphql_data.GraphqlData( + problemset_question_list=leetcode.models.graphql_problemset_question_list.GraphqlProblemsetQuestionList( + questions=[ + QUESTION_DETAIL, + ], + total_num=1, + ) + ) response = leetcode.models.graphql_response.GraphqlResponse(data=data) self._leetcode_data._api_instance.graphql_post.return_value = response - assert ( - await self._leetcode_data._get_problem_data("test") - ) == self._question_details + assert self._leetcode_data._get_problems_data_page(0, 10, 0) == [ + QUESTION_DETAIL, + ] - assert self._leetcode_data._cache["test"] == self._question_details + @pytest.mark.asyncio + @mock.patch( + "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_count", + mock.Mock(return_value=234), + ) + @mock.patch("leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data_page") + async def test_get_problems_data(self, mock_get_problems_data_page) -> None: + question_list = [QUESTION_DETAIL] * 234 + + def dummy( + offset: int, page_size: int, page: int + ) -> List[leetcode.models.graphql_question_detail.GraphqlQuestionDetail]: + return [ + question_list.pop() for _ in range(min(page_size, len(question_list))) + ] + + mock_get_problems_data_page.side_effect = dummy + + assert len(self._leetcode_data._get_problems_data()) == 234