diff --git a/.github/workflows/build-deck.yml b/.github/workflows/build-deck.yml index b9faa8e..39987ce 100644 --- a/.github/workflows/build-deck.yml +++ b/.github/workflows/build-deck.yml @@ -1,6 +1,6 @@ --- name: Build Anki deck -on: push +on: [push, pull_request] jobs: build-anki-deck: name: Build Anki deck @@ -13,6 +13,8 @@ jobs: python-version: 3.9 - name: Install requirements run: pip install -r requirements.txt + - name: Install sqlite + run: sudo apt-get install sqlite3 unzip - name: Get current date id: date run: echo "::set-output name=date::$(date +'%Y-%m-%d_%H:%M:%S')" @@ -20,14 +22,36 @@ jobs: id: timestamp run: echo "::set-output name=timestamp::$(date +'%s')" - name: Test build Anki Deck - run: python generate.py --stop 3 + run: > + git clean -f -x -d + && python generate.py --start 1 --stop 5 --page-size 2 + && unzip leetcode.apkg + && sqlite3 collection.anki2 .schema + && sqlite3 collection.anki2 .dump env: LEETCODE_SESSION_ID: ${{ secrets.LEETCODE_SESSION_ID }} + LEETCODE_CSRF_TOKEN: ${{ secrets.LEETCODE_CSRF_TOKEN }} + - name: Test build Anki Deck (non-default output file) + run: > + git clean -f -x -d + && python generate.py --stop 3 --output-file test.apkg + && unzip test.apkg + && sqlite3 collection.anki2 .schema + && sqlite3 collection.anki2 .dump + env: + LEETCODE_SESSION_ID: ${{ secrets.LEETCODE_SESSION_ID }} + LEETCODE_CSRF_TOKEN: ${{ secrets.LEETCODE_CSRF_TOKEN }} + - name: Test build Anki Deck with Amazon list id + run: python generate.py --stop 10 --list-id 7p5x763 + env: + LEETCODE_SESSION_ID: ${{ secrets.LEETCODE_SESSION_ID }} + LEETCODE_CSRF_TOKEN: ${{ secrets.LEETCODE_CSRF_TOKEN }} - name: Build Anki Deck run: python generate.py if: github.ref == 'refs/heads/master' env: LEETCODE_SESSION_ID: ${{ secrets.LEETCODE_SESSION_ID }} + LEETCODE_CSRF_TOKEN: ${{ secrets.LEETCODE_CSRF_TOKEN }} - name: Create Release id: create_release uses: actions/create-release@v1 @@ -48,9 +72,3 @@ jobs: asset_path: ./leetcode.apkg asset_name: leetcode.apkg asset_content_type: application/octet-stream - - name: Publish release - uses: StuYarrow/publish-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - id: ${{ steps.create_release.outputs.id }} diff --git a/.github/workflows/style-check.yml b/.github/workflows/style-check.yml index 47f6c2e..b0ab073 100644 --- a/.github/workflows/style-check.yml +++ b/.github/workflows/style-check.yml @@ -1,6 +1,6 @@ --- name: Style -on: push +on: [push, pull_request] jobs: pylint: name: pylint @@ -13,10 +13,12 @@ jobs: python-version: 3.9 - name: Install requirements run: pip install -r requirements.txt + - name: Install test requirements + run: pip install -r test-requirements.txt - name: Install pylint run: pip install pylint - name: Run pylint - run: pylint -E generate.py + run: find . -type f -name "*.py" | xargs pylint -E black: name: black runs-on: ubuntu-latest @@ -31,7 +33,7 @@ jobs: - name: Install black run: pip install black - name: Run black - run: black --diff . + run: black --check --diff . isort: name: isort runs-on: ubuntu-latest @@ -46,4 +48,4 @@ jobs: - name: Install isort run: pip install isort - name: Run isort - run: isort --ensure-newline-before-comments --diff generate.py + run: isort --ensure-newline-before-comments --diff -v . diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d76679c..d9f854e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,6 @@ --- name: Tests -on: push +on: [push, pull_request] jobs: pytest: name: pytest diff --git a/.github/workflows/type-check.yml b/.github/workflows/type-check.yml index 139f1c1..bc70904 100644 --- a/.github/workflows/type-check.yml +++ b/.github/workflows/type-check.yml @@ -1,6 +1,6 @@ --- name: Typing -on: push +on: [push, pull_request] jobs: mypy: name: mypy @@ -19,3 +19,20 @@ jobs: run: pip install mypy - name: Run mypy run: mypy . + pyre: + name: pyre + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - name: Set up Python 3.9 + uses: actions/setup-python@v1 + with: + python-version: 3.9 + - name: Install requirements + run: pip install -r requirements.txt + - name: Install test requirements + run: pip install -r test-requirements.txt + - name: Install pyre + run: pip install pyre-check + - name: Run pyre + run: pyre check diff --git a/.pyre_configuration b/.pyre_configuration index 9573b80..cf73b56 100644 --- a/.pyre_configuration +++ b/.pyre_configuration @@ -1,5 +1,7 @@ { "source_directories": [ "." - ] + ], + "site_package_search_strategy": "all", + "strict": true } diff --git a/README.md b/README.md index 2ad4129..8fbd8a3 100644 --- a/README.md +++ b/README.md @@ -27,25 +27,47 @@ I personally use it to track my grinding progress. ## How to run First download the source code -``` +```sh git clone https://github.com/prius/leetcode-anki.git cd leetcode-anki ``` After that initialize and activate python virtualenv somewhere -``` + +Linux/MacOS +```sh virtualenv -p python leetcode-anki . leetcode-anki/bin/activate ``` -Then initialize session id variable. You can get it directly from your browser (if you're using chrome, cookies can be found here chrome://settings/cookies/detail?site=leetcode.com) +Windows +```sh +python -m venv leetcode-anki +.\leetcode-anki\Scripts\activate.bat ``` + +Then initialize necessary environment variables. You can get it directly from your browser cookies (`csrftoken` and `LEETCODE_SESSION`) + +Linux/Macos +```sh +export LEETCODE_CSRF_TOKEN="xxx" export LEETCODE_SESSION_ID="yyy" ``` -And finally run +Windows +```sh +set LEETCODE_CSRF_TOKEN="xxx" +set LEETCODE_SESSION_ID="yyy" ``` + +And finally run for Linux/MacOS +```sh make generate ``` +Or for Windows +```sh +pip install -r requirements.txt +python generate.py +``` You'll get `leetcode.apkg` file, which you can import directly to your anki app. diff --git a/generate.py b/generate.py index 605c533..dafcfc5 100755 --- a/generate.py +++ b/generate.py @@ -7,7 +7,8 @@ import argparse import asyncio import logging -from typing import Any, Coroutine, List +from pathlib import Path +from typing import Any, Awaitable, Callable, Coroutine, List # https://github.com/kerrickstaley/genanki import genanki # type: ignore @@ -39,7 +40,16 @@ def parse_args() -> argparse.Namespace: "--page-size", type=int, help="Get at most this many problems (decrease if leetcode API times out)", - default=1000, + default=500, + ) + parser.add_argument( + "--list-id", + type=str, + help="Get all questions from a specific list id (https://leetcode.com/list?selectedList=", + default="", + ) + parser.add_argument( + "--output-file", type=str, help="Output filename", default=OUTPUT_FILE ) args = parser.parse_args() @@ -54,7 +64,7 @@ class LeetcodeNote(genanki.Note): """ @property - def guid(self): + def guid(self) -> str: # Hash by leetcode task handle return genanki.guid_for(self.fields[0]) @@ -96,7 +106,9 @@ async def generate_anki_note( ) -async def generate(start: int, stop: int, page_size: int) -> None: +async def generate( + start: int, stop: int, page_size: int, list_id: str, output_file: str +) -> None: """ Generate an Anki deck """ @@ -158,31 +170,29 @@ async def generate(start: int, stop: int, page_size: int) -> None:
""", - }, + } ], ) - leetcode_deck = genanki.Deck(LEETCODE_ANKI_DECK_ID, "leetcode") + leetcode_deck = genanki.Deck(LEETCODE_ANKI_DECK_ID, Path(output_file).stem) - leetcode_data = leetcode_anki.helpers.leetcode.LeetcodeData(start, stop, page_size) + leetcode_data = leetcode_anki.helpers.leetcode.LeetcodeData( + start, stop, page_size, list_id + ) - note_generators: List[Coroutine[Any, Any, LeetcodeNote]] = [] + note_generators: List[Awaitable[LeetcodeNote]] = [] 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, - ) + generate_anki_note(leetcode_data, leetcode_model, leetcode_task_handle) ) 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) + genanki.Package(leetcode_deck).write_to_file(output_file) async def main() -> None: @@ -191,10 +201,16 @@ async def main() -> None: """ args = parse_args() - start, stop, page_size = args.start, args.stop, args.page_size - await generate(start, stop, page_size) + start, stop, page_size, list_id, output_file = ( + args.start, + args.stop, + args.page_size, + args.list_id, + args.output_file, + ) + await generate(start, stop, page_size, list_id, output_file) if __name__ == "__main__": - loop = asyncio.get_event_loop() + loop: asyncio.events.AbstractEventLoop = asyncio.get_event_loop() loop.run_until_complete(main()) diff --git a/leetcode_anki/helpers/leetcode.py b/leetcode_anki/helpers/leetcode.py index 3de1267..e7b810b 100644 --- a/leetcode_anki/helpers/leetcode.py +++ b/leetcode_anki/helpers/leetcode.py @@ -1,3 +1,4 @@ +# pylint: disable=missing-module-docstring import functools import json import logging @@ -5,7 +6,7 @@ import os import time from functools import cached_property -from typing import Callable, Dict, List, Tuple, Type +from typing import Any, Callable, Dict, List, Tuple, Type, TypeVar # https://github.com/prius/python-leetcode import leetcode.api.default_api # type: ignore @@ -34,7 +35,7 @@ def _get_leetcode_api_client() -> leetcode.api.default_api.DefaultApi: configuration = leetcode.configuration.Configuration() session_id = os.environ["LEETCODE_SESSION_ID"] - csrf_token = leetcode.auth.get_csrf_cookie(session_id) + csrf_token = os.environ["LEETCODE_CSRF_TOKEN"] configuration.api_key["x-csrftoken"] = csrf_token configuration.api_key["csrftoken"] = csrf_token @@ -48,16 +49,28 @@ def _get_leetcode_api_client() -> leetcode.api.default_api.DefaultApi: return api_instance -def retry(times: int, exceptions: Tuple[Type[Exception]], delay: float) -> Callable: - """ - Retry Decorator - Retries the wrapped function/method `times` times if the exceptions listed - in `exceptions` are thrown - """ +_T = TypeVar("_T") + + +class _RetryDecorator: + _times: int + _exceptions: Tuple[Type[Exception]] + _delay: float + + def __init__( + self, times: int, exceptions: Tuple[Type[Exception]], delay: float + ) -> None: + self._times = times + self._exceptions = exceptions + self._delay = delay + + def __call__(self, func: Callable[..., _T]) -> Callable[..., _T]: + times: int = self._times + exceptions: Tuple[Type[Exception]] = self._exceptions + delay: float = self._delay - def decorator(func): @functools.wraps(func) - def wrapper(*args, **kwargs): + def wrapper(*args: Any, **kwargs: Any) -> _T: for attempt in range(times - 1): try: return func(*args, **kwargs) @@ -72,7 +85,17 @@ def wrapper(*args, **kwargs): return wrapper - return decorator + +def retry( + times: int, exceptions: Tuple[Type[Exception]], delay: float +) -> _RetryDecorator: + """ + Retry Decorator + Retries the wrapped function/method `times` times if the exceptions listed + in `exceptions` are thrown + """ + + return _RetryDecorator(times, exceptions, delay) class LeetcodeData: @@ -83,7 +106,9 @@ class LeetcodeData: names. """ - def __init__(self, start: int, stop: int, page_size: int = 1000) -> None: + def __init__( + self, start: int, stop: int, page_size: int = 1000, list_id: str = "" + ) -> None: """ Initialize leetcode API and disk cache for API responses """ @@ -102,6 +127,7 @@ def __init__(self, start: int, stop: int, page_size: int = 1000) -> None: self._start = start self._stop = stop self._page_size = page_size + self._list_id = list_id @cached_property def _api_instance(self) -> leetcode.api.default_api.DefaultApi: @@ -140,6 +166,7 @@ def _get_problems_count(self) -> int: skip=0, filters=leetcode.models.graphql_query_problemset_question_list_variables_filter_input.GraphqlQueryProblemsetQuestionListVariablesFilterInput( tags=[], + list_id=self._list_id, # difficulty="MEDIUM", # status="NOT_STARTED", # list_id="7p5x763", # Top Amazon Questions @@ -193,7 +220,9 @@ def _get_problems_data_page( category_slug="", limit=page_size, skip=offset + page * page_size, - filters=leetcode.models.graphql_query_problemset_question_list_variables_filter_input.GraphqlQueryProblemsetQuestionListVariablesFilterInput(), + filters=leetcode.models.graphql_query_problemset_question_list_variables_filter_input.GraphqlQueryProblemsetQuestionListVariablesFilterInput( + list_id=self._list_id + ), ), operation_name="problemsetQuestionList", ) @@ -224,7 +253,7 @@ def _get_problems_data( leetcode.models.graphql_question_detail.GraphqlQuestionDetail ] = [] - logging.info(f"Fetching {stop - start + 1} problems {page_size} per page") + logging.info("Fetching %s problems %s per page", stop - start + 1, page_size) for page in tqdm( range(math.ceil((stop - start + 1) / page_size)), @@ -255,6 +284,8 @@ def _get_problem_data( if problem_slug in cache: return cache[problem_slug] + raise ValueError(f"Problem {problem_slug} is not in cache") + async def _get_description(self, problem_slug: str) -> str: """ Problem description @@ -349,7 +380,9 @@ async def tags(self, problem_slug: str) -> List[str]: List of the tags for this problem (string slugs) """ data = self._get_problem_data(problem_slug) - return list(map(lambda x: x.slug, data.topic_tags)) + tags = list(map(lambda x: x.slug, data.topic_tags)) + tags.append(f"difficulty-{data.difficulty.lower()}-tag") + return tags async def freq_bar(self, problem_slug: str) -> float: """ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..953fe9f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[tool.pytest.ini_options] +asyncio_mode = "strict" +testpaths = [ + "test", +] + +[tool.pylint] +max-line-length = 88 +disable = ["line-too-long"] diff --git a/test-requirements.txt b/test-requirements.txt index e079f8a..ee4ba01 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1 +1,2 @@ pytest +pytest-asyncio diff --git a/test/helpers/test_leetcode.py b/test/helpers/test_leetcode.py index dff81b4..19c4f41 100644 --- a/test/helpers/test_leetcode.py +++ b/test/helpers/test_leetcode.py @@ -36,7 +36,7 @@ username="testcontributor", profile_url="test://profile/url", avatar_url="test://avatar/url", - ), + ) ], lang_to_valid_playground="{}", topic_tags=[ @@ -53,10 +53,8 @@ hints=["test hint 1", "test hint 2"], solution=[ leetcode.models.graphql_question_solution.GraphqlQuestionSolution( - id=1, - can_see_detail=False, - typename="test type name", - ), + id=1, can_see_detail=False, typename="test type name" + ) ], status="ac", sample_test_case="test case", @@ -77,12 +75,17 @@ def dummy_return_question_detail_dict( @mock.patch("os.environ", mock.MagicMock(return_value={"LEETCODE_SESSION_ID": "test"})) +@mock.patch("os.environ", mock.MagicMock(return_value={"LEETCODE_CSRF_TOKEN": "test"})) @mock.patch("leetcode.auth", mock.MagicMock()) class TestLeetcode: + # pyre-fixme[56]: Pyre was not able to infer the type of the decorator + # `pytest.mark.asyncio`. @pytest.mark.asyncio async def test_get_leetcode_api_client(self) -> None: assert leetcode_anki.helpers.leetcode._get_leetcode_api_client() + # pyre-fixme[56]: Pyre was not able to infer the type of the decorator + # `pytest.mark.asyncio`. @pytest.mark.asyncio async def test_retry(self) -> None: decorator = leetcode_anki.helpers.leetcode.retry( @@ -106,9 +109,9 @@ class TestLeetcodeData: _question_detail_singleton: Optional[ leetcode.models.graphql_question_detail.GraphqlQuestionDetail ] = None - _leetcode_data_singleton: Optional[ - leetcode_anki.helpers.leetcode.LeetcodeData - ] = None + _leetcode_data_singleton: Optional[leetcode_anki.helpers.leetcode.LeetcodeData] = ( + None + ) @property def _question_details( @@ -130,12 +133,14 @@ def _leetcode_data(self) -> leetcode_anki.helpers.leetcode.LeetcodeData: return leetcode_data - def setup(self) -> None: + def setup_method(self) -> None: self._question_detail_singleton = QUESTION_DETAIL self._leetcode_data_singleton = leetcode_anki.helpers.leetcode.LeetcodeData( 0, 10000 ) + # pyre-fixme[56]: Pyre was not able to infer the type of the decorator + # `pytest.mark.asyncio`. @pytest.mark.asyncio @mock.patch( "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", @@ -144,6 +149,8 @@ def setup(self) -> None: async def test_init(self) -> None: self._leetcode_data._cache["test"] = QUESTION_DETAIL + # pyre-fixme[56]: Pyre was not able to infer the type of the decorator + # `pytest.mark.asyncio`. @pytest.mark.asyncio @mock.patch( "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", @@ -153,6 +160,8 @@ async def test_get_description(self) -> None: self._leetcode_data._cache["test"] = QUESTION_DETAIL assert (await self._leetcode_data.description("test")) == "test content" + # pyre-fixme[56]: Pyre was not able to infer the type of the decorator + # `pytest.mark.asyncio`. @pytest.mark.asyncio @mock.patch( "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", @@ -164,6 +173,8 @@ async def test_submissions(self) -> None: assert (await self._leetcode_data.submissions_total("test")) == 1 assert (await self._leetcode_data.submissions_accepted("test")) == 1 + # pyre-fixme[56]: Pyre was not able to infer the type of the decorator + # `pytest.mark.asyncio`. @pytest.mark.asyncio @mock.patch( "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", @@ -175,6 +186,8 @@ async def test_difficulty_easy(self) -> None: QUESTION_DETAIL.difficulty = "Easy" assert "Easy" in (await self._leetcode_data.difficulty("test")) + # pyre-fixme[56]: Pyre was not able to infer the type of the decorator + # `pytest.mark.asyncio`. @pytest.mark.asyncio @mock.patch( "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", @@ -186,6 +199,8 @@ async def test_difficulty_medium(self) -> None: QUESTION_DETAIL.difficulty = "Medium" assert "Medium" in (await self._leetcode_data.difficulty("test")) + # pyre-fixme[56]: Pyre was not able to infer the type of the decorator + # `pytest.mark.asyncio`. @pytest.mark.asyncio @mock.patch( "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", @@ -197,6 +212,8 @@ async def test_difficulty_hard(self) -> None: QUESTION_DETAIL.difficulty = "Hard" assert "Hard" in (await self._leetcode_data.difficulty("test")) + # pyre-fixme[56]: Pyre was not able to infer the type of the decorator + # `pytest.mark.asyncio`. @pytest.mark.asyncio @mock.patch( "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", @@ -207,6 +224,8 @@ async def test_paid(self) -> None: assert (await self._leetcode_data.paid("test")) is False + # pyre-fixme[56]: Pyre was not able to infer the type of the decorator + # `pytest.mark.asyncio`. @pytest.mark.asyncio @mock.patch( "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", @@ -217,6 +236,8 @@ async def test_problem_id(self) -> None: assert (await self._leetcode_data.problem_id("test")) == "1" + # pyre-fixme[56]: Pyre was not able to infer the type of the decorator + # `pytest.mark.asyncio`. @pytest.mark.asyncio @mock.patch( "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", @@ -227,6 +248,8 @@ async def test_likes(self) -> None: assert (await self._leetcode_data.likes("test")) == 1 + # pyre-fixme[56]: Pyre was not able to infer the type of the decorator + # `pytest.mark.asyncio`. @pytest.mark.asyncio @mock.patch( "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", @@ -237,6 +260,8 @@ async def test_dislikes(self) -> None: assert (await self._leetcode_data.dislikes("test")) == 1 + # pyre-fixme[56]: Pyre was not able to infer the type of the decorator + # `pytest.mark.asyncio`. @pytest.mark.asyncio @mock.patch( "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", @@ -245,8 +270,13 @@ async def test_dislikes(self) -> None: async def test_tags(self) -> None: self._leetcode_data._cache["test"] = QUESTION_DETAIL - assert (await self._leetcode_data.tags("test")) == ["test-tag"] + assert (await self._leetcode_data.tags("test")) == [ + "test-tag", + "difficulty-hard-tag", + ] + # pyre-fixme[56]: Pyre was not able to infer the type of the decorator + # `pytest.mark.asyncio`. @pytest.mark.asyncio @mock.patch( "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", @@ -257,6 +287,8 @@ async def test_freq_bar(self) -> None: assert (await self._leetcode_data.freq_bar("test")) == 1.1 + # pyre-fixme[56]: Pyre was not able to infer the type of the decorator + # `pytest.mark.asyncio`. @pytest.mark.asyncio @mock.patch( "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", @@ -266,31 +298,36 @@ async def test_get_problem_data(self) -> None: assert self._leetcode_data._cache["test"] == QUESTION_DETAIL @mock.patch("time.sleep", mock.Mock()) + # pyre-fixme[56]: Pyre was not able to infer the type of the decorator + # `pytest.mark.asyncio`. @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, + 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 self._leetcode_data._get_problems_data_page(0, 10, 0) == [ - QUESTION_DETAIL, + QUESTION_DETAIL ] + # pyre-fixme[56]: Pyre was not able to infer the type of the decorator + # `pytest.mark.asyncio`. @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 + async def test_get_problems_data( + self, mock_get_problems_data_page: mock.Mock + ) -> None: + question_list: List[ + leetcode.models.graphql_question_detail.GraphqlQuestionDetail + ] = [QUESTION_DETAIL] * 234 def dummy( offset: int, page_size: int, page: int