├── .github └── workflows │ ├── build-deck.yml │ ├── style-check.yml │ ├── tests.yml │ └── type-check.yml ├── .gitignore ├── .isort.cfg ├── .pyre_configuration ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── generate.py ├── leetcode_anki ├── __init__.py └── helpers │ ├── __init__.py │ └── leetcode.py ├── pyproject.toml ├── requirements.txt ├── test-requirements.txt └── test ├── __init__.py ├── helpers ├── __init__.py └── test_leetcode.py └── test_dummy.py /.github/workflows/build-deck.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build Anki deck 3 | on: [push, pull_request] 4 | jobs: 5 | build-anki-deck: 6 | name: Build Anki deck 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@master 10 | - name: Set up Python 3.9 11 | uses: actions/setup-python@v1 12 | with: 13 | python-version: 3.9 14 | - name: Install requirements 15 | run: pip install -r requirements.txt 16 | - name: Install sqlite 17 | run: sudo apt-get install sqlite3 unzip 18 | - name: Get current date 19 | id: date 20 | run: echo "::set-output name=date::$(date +'%Y-%m-%d_%H:%M:%S')" 21 | - name: Get current timestamp 22 | id: timestamp 23 | run: echo "::set-output name=timestamp::$(date +'%s')" 24 | - name: Test build Anki Deck 25 | run: > 26 | git clean -f -x -d 27 | && python generate.py --start 1 --stop 5 --page-size 2 28 | && unzip leetcode.apkg 29 | && sqlite3 collection.anki2 .schema 30 | && sqlite3 collection.anki2 .dump 31 | env: 32 | LEETCODE_SESSION_ID: ${{ secrets.LEETCODE_SESSION_ID }} 33 | LEETCODE_CSRF_TOKEN: ${{ secrets.LEETCODE_CSRF_TOKEN }} 34 | - name: Test build Anki Deck (non-default output file) 35 | run: > 36 | git clean -f -x -d 37 | && python generate.py --stop 3 --output-file test.apkg 38 | && unzip test.apkg 39 | && sqlite3 collection.anki2 .schema 40 | && sqlite3 collection.anki2 .dump 41 | env: 42 | LEETCODE_SESSION_ID: ${{ secrets.LEETCODE_SESSION_ID }} 43 | LEETCODE_CSRF_TOKEN: ${{ secrets.LEETCODE_CSRF_TOKEN }} 44 | - name: Test build Anki Deck with Amazon list id 45 | run: python generate.py --stop 10 --list-id 7p5x763 46 | env: 47 | LEETCODE_SESSION_ID: ${{ secrets.LEETCODE_SESSION_ID }} 48 | LEETCODE_CSRF_TOKEN: ${{ secrets.LEETCODE_CSRF_TOKEN }} 49 | - name: Build Anki Deck 50 | run: python generate.py 51 | if: github.ref == 'refs/heads/master' 52 | env: 53 | LEETCODE_SESSION_ID: ${{ secrets.LEETCODE_SESSION_ID }} 54 | LEETCODE_CSRF_TOKEN: ${{ secrets.LEETCODE_CSRF_TOKEN }} 55 | - name: Create Release 56 | id: create_release 57 | uses: actions/create-release@v1 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | with: 61 | tag_name: ${{ github.ref }}-${{ steps.timestamp.outputs.timestamp }} 62 | release_name: > 63 | Anki Deck from ${{ github.ref }} on ${{ steps.date.outputs.date }} 64 | draft: true 65 | prerelease: true 66 | - name: Upload release asset 67 | uses: actions/upload-release-asset@v1 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | with: 71 | upload_url: ${{ steps.create_release.outputs.upload_url }} 72 | asset_path: ./leetcode.apkg 73 | asset_name: leetcode.apkg 74 | asset_content_type: application/octet-stream 75 | -------------------------------------------------------------------------------- /.github/workflows/style-check.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Style 3 | on: [push, pull_request] 4 | jobs: 5 | pylint: 6 | name: pylint 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@master 10 | - name: Set up Python 3.9 11 | uses: actions/setup-python@v1 12 | with: 13 | python-version: 3.9 14 | - name: Install requirements 15 | run: pip install -r requirements.txt 16 | - name: Install test requirements 17 | run: pip install -r test-requirements.txt 18 | - name: Install pylint 19 | run: pip install pylint 20 | - name: Run pylint 21 | run: find . -type f -name "*.py" | xargs pylint -E 22 | black: 23 | name: black 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@master 27 | - name: Set up Python 3.9 28 | uses: actions/setup-python@v1 29 | with: 30 | python-version: 3.9 31 | - name: Install requirements 32 | run: pip install -r requirements.txt 33 | - name: Install black 34 | run: pip install black 35 | - name: Run black 36 | run: black --check --diff . 37 | isort: 38 | name: isort 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@master 42 | - name: Set up Python 3.9 43 | uses: actions/setup-python@v1 44 | with: 45 | python-version: 3.9 46 | - name: Install requirements 47 | run: pip install -r requirements.txt 48 | - name: Install isort 49 | run: pip install isort 50 | - name: Run isort 51 | run: isort --ensure-newline-before-comments --diff -v . 52 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests 3 | on: [push, pull_request] 4 | jobs: 5 | pytest: 6 | name: pytest 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@master 10 | - name: Set up Python 3.9 11 | uses: actions/setup-python@v1 12 | with: 13 | python-version: 3.9 14 | - name: Install requirements 15 | run: pip install -r requirements.txt 16 | - name: Install test requirements 17 | run: pip install -r test-requirements.txt 18 | - name: Install pytest 19 | run: pip install pytest 20 | - name: Run pytest 21 | run: pytest 22 | -------------------------------------------------------------------------------- /.github/workflows/type-check.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Typing 3 | on: [push, pull_request] 4 | jobs: 5 | mypy: 6 | name: mypy 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@master 10 | - name: Set up Python 3.9 11 | uses: actions/setup-python@v1 12 | with: 13 | python-version: 3.9 14 | - name: Install requirements 15 | run: pip install -r requirements.txt 16 | - name: Install test requirements 17 | run: pip install -r test-requirements.txt 18 | - name: Install mypy 19 | run: pip install mypy 20 | - name: Run mypy 21 | run: mypy . 22 | pyre: 23 | name: pyre 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@master 27 | - name: Set up Python 3.9 28 | uses: actions/setup-python@v1 29 | with: 30 | python-version: 3.9 31 | - name: Install requirements 32 | run: pip install -r requirements.txt 33 | - name: Install test requirements 34 | run: pip install -r test-requirements.txt 35 | - name: Install pyre 36 | run: pip install pyre-check 37 | - name: Run pyre 38 | run: pyre check 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cache 2 | leetcode.apkg 3 | .mypy_cache 4 | .cookies.sh 5 | __pycache__ 6 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | force_grid_wrap=0 3 | include_trailing_comma=True 4 | line_length=88 5 | multi_line_output=3 6 | use_parentheses=True 7 | ensure_newline_before_comments=True 8 | -------------------------------------------------------------------------------- /.pyre_configuration: -------------------------------------------------------------------------------- 1 | { 2 | "source_directories": [ 3 | "." 4 | ], 5 | "site_package_search_strategy": "all", 6 | "strict": true 7 | } 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '3.9' 4 | install: 5 | - pip install -r requirements.txt 6 | - pip install awscli 7 | script: 8 | jobs: 9 | include: 10 | # Each step caches fetched problems from the previous one 11 | # so the next one runs faster. 12 | # This is a hack because travis CI has a time limit of 30 13 | # minutes for each individual job 14 | - stage: 0 to 2 (test run) 15 | script: 16 | - python generate.py --start 0 --stop 2 17 | - aws s3 sync cache s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER 18 | - stage: 2 to 500 19 | script: 20 | - aws s3 sync s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER cache 21 | - python generate.py --start 0 --stop 500 22 | - aws s3 sync cache s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER 23 | - stage: 500 to 1000 24 | script: 25 | - aws s3 sync s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER cache 26 | - python generate.py --start 0 --stop 1000 27 | - aws s3 sync cache s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER 28 | - stage: 1000 to 1500 29 | script: 30 | - aws s3 sync s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER cache 31 | - python generate.py --start 0 --stop 1500 32 | - aws s3 sync cache s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER 33 | - stage: 1500 to 2000 34 | script: 35 | - aws s3 sync s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER cache 36 | - python generate.py --start 0 --stop 2000 37 | - aws s3 sync cache s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER 38 | - stage: 2000 to 2500 39 | script: 40 | - aws s3 sync s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER cache 41 | - python generate.py --start 0 --stop 2500 42 | - aws s3 sync cache s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER 43 | - stage: 2500 to 3000 44 | script: 45 | - aws s3 sync s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER cache 46 | - python generate.py --start 0 --stop 3000 47 | - aws s3 rm --recursive s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER 48 | deploy: 49 | provider: releases 50 | api_key: $GITHUB_TOKEN 51 | file: leetcode.apkg 52 | skip_cleanup: true 53 | on: 54 | branch: master 55 | after_failure: 56 | - aws s3 rm --recursive s3://github-prius-travis-ci-us-east-1/leetcode-anki-$TRAVIS_BUILD_NUMBER 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | generate: 2 | # You have to set the variables below in order to 3 | # authenticate on leetcode. It is required to read 4 | # the information about the problems 5 | test ! "x${VIRTUAL_ENV}" = "x" || (echo "Need to run inside venv" && exit 1) 6 | pip install -r requirements.txt 7 | python3 generate.py 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![build](https://github.com/prius/leetcode-anki/actions/workflows/build-deck.yml/badge.svg) 3 | ![style](https://github.com/prius/leetcode-anki/actions/workflows/style-check.yml/badge.svg) 4 | ![tests](https://github.com/prius/leetcode-anki/actions/workflows/tests.yml/badge.svg) 5 | ![types](https://github.com/prius/leetcode-anki/actions/workflows/type-check.yml/badge.svg) 6 | ![license](https://img.shields.io/github/license/prius/leetcode-anki) 7 | 8 | # Leetcode Anki card generator 9 | 10 | ## Summary 11 | By running this script you'll be able to generate Anki cards with all the leetcode problems. 12 | 13 | I personally use it to track my grinding progress. 14 | 15 | ![ezgif-7-03b29041a91e](https://user-images.githubusercontent.com/1616237/134259809-57af6afb-8885-4899-adf8-a2639977baeb.gif) 16 | 17 | ![photo_2021-09-29_08-58-19 jpg 2](https://user-images.githubusercontent.com/1616237/135676120-6a83229d-9715-45fb-8f85-1b1b27d96f9b.png) 18 | ![photo_2021-09-29_08-58-21 jpg 2](https://user-images.githubusercontent.com/1616237/135676123-106871e0-bc8e-4d23-acef-c27ebe034ecf.png) 19 | ![photo_2021-09-29_08-58-23 jpg 2](https://user-images.githubusercontent.com/1616237/135676125-90067ea3-e111-49da-ae13-7bce81040c37.png) 20 | 21 | ## Prerequisites 22 | 1. [python3.8+](https://www.python.org/downloads/) installed 23 | 2. [python virtualenv](https://pypi.org/project/virtualenv/) installed 24 | 3. [git cli](https://github.com/git-guides/install-git) installed 25 | 4. [GNU make](https://www.gnu.org/software/make/) installed (optional, can run the script directly) 26 | 5. \*nix operating system (Linux, MacOS, FreeBSD, ...). Should also work for Windows, but commands will be different. I'm not a Windows expert, so can't figure out how to make it work there, but contributions are welcome. 27 | 28 | ## How to run 29 | First download the source code 30 | ```sh 31 | git clone https://github.com/prius/leetcode-anki.git 32 | cd leetcode-anki 33 | ``` 34 | 35 | After that initialize and activate python virtualenv somewhere 36 | 37 | Linux/MacOS 38 | ```sh 39 | virtualenv -p python leetcode-anki 40 | . leetcode-anki/bin/activate 41 | ``` 42 | 43 | Windows 44 | ```sh 45 | python -m venv leetcode-anki 46 | .\leetcode-anki\Scripts\activate.bat 47 | ``` 48 | 49 | Then initialize necessary environment variables. You can get it directly from your browser cookies (`csrftoken` and `LEETCODE_SESSION`) 50 | 51 | Linux/Macos 52 | ```sh 53 | export LEETCODE_CSRF_TOKEN="xxx" 54 | export LEETCODE_SESSION_ID="yyy" 55 | ``` 56 | 57 | Windows 58 | ```sh 59 | set LEETCODE_CSRF_TOKEN="xxx" 60 | set LEETCODE_SESSION_ID="yyy" 61 | ``` 62 | 63 | And finally run for Linux/MacOS 64 | ```sh 65 | make generate 66 | ``` 67 | Or for Windows 68 | ```sh 69 | pip install -r requirements.txt 70 | python generate.py 71 | ``` 72 | 73 | You'll get `leetcode.apkg` file, which you can import directly to your anki app. 74 | -------------------------------------------------------------------------------- /generate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | This script generates an Anki deck with all the leetcode problems currently 4 | known. 5 | """ 6 | 7 | import argparse 8 | import asyncio 9 | import logging 10 | from pathlib import Path 11 | from typing import Any, Awaitable, Callable, Coroutine, List 12 | 13 | # https://github.com/kerrickstaley/genanki 14 | import genanki # type: ignore 15 | from tqdm import tqdm # type: ignore 16 | 17 | import leetcode_anki.helpers.leetcode 18 | 19 | LEETCODE_ANKI_MODEL_ID = 4567610856 20 | LEETCODE_ANKI_DECK_ID = 8589798175 21 | OUTPUT_FILE = "leetcode.apkg" 22 | ALLOWED_EXTENSIONS = {".py", ".go"} 23 | 24 | 25 | logging.getLogger().setLevel(logging.INFO) 26 | 27 | 28 | def parse_args() -> argparse.Namespace: 29 | """ 30 | Parse command line arguments for the script 31 | """ 32 | parser = argparse.ArgumentParser(description="Generate Anki cards for leetcode") 33 | parser.add_argument( 34 | "--start", type=int, help="Start generation from this problem", default=0 35 | ) 36 | parser.add_argument( 37 | "--stop", type=int, help="Stop generation on this problem", default=2**64 38 | ) 39 | parser.add_argument( 40 | "--page-size", 41 | type=int, 42 | help="Get at most this many problems (decrease if leetcode API times out)", 43 | default=500, 44 | ) 45 | parser.add_argument( 46 | "--list-id", 47 | type=str, 48 | help="Get all questions from a specific list id (https://leetcode.com/list?selectedList=", 49 | default="", 50 | ) 51 | parser.add_argument( 52 | "--output-file", type=str, help="Output filename", default=OUTPUT_FILE 53 | ) 54 | 55 | args = parser.parse_args() 56 | 57 | return args 58 | 59 | 60 | class LeetcodeNote(genanki.Note): 61 | """ 62 | Extended base class for the Anki note, that correctly sets the unique 63 | identifier of the note. 64 | """ 65 | 66 | @property 67 | def guid(self) -> str: 68 | # Hash by leetcode task handle 69 | return genanki.guid_for(self.fields[0]) 70 | 71 | 72 | async def generate_anki_note( 73 | leetcode_data: leetcode_anki.helpers.leetcode.LeetcodeData, 74 | leetcode_model: genanki.Model, 75 | leetcode_task_handle: str, 76 | ) -> LeetcodeNote: 77 | """ 78 | Generate a single Anki flashcard 79 | """ 80 | return LeetcodeNote( 81 | model=leetcode_model, 82 | fields=[ 83 | leetcode_task_handle, 84 | str(await leetcode_data.problem_id(leetcode_task_handle)), 85 | str(await leetcode_data.title(leetcode_task_handle)), 86 | str(await leetcode_data.category(leetcode_task_handle)), 87 | await leetcode_data.description(leetcode_task_handle), 88 | await leetcode_data.difficulty(leetcode_task_handle), 89 | "yes" if await leetcode_data.paid(leetcode_task_handle) else "no", 90 | str(await leetcode_data.likes(leetcode_task_handle)), 91 | str(await leetcode_data.dislikes(leetcode_task_handle)), 92 | str(await leetcode_data.submissions_total(leetcode_task_handle)), 93 | str(await leetcode_data.submissions_accepted(leetcode_task_handle)), 94 | str( 95 | int( 96 | await leetcode_data.submissions_accepted(leetcode_task_handle) 97 | / await leetcode_data.submissions_total(leetcode_task_handle) 98 | * 100 99 | ) 100 | ), 101 | str(await leetcode_data.freq_bar(leetcode_task_handle)), 102 | ], 103 | tags=await leetcode_data.tags(leetcode_task_handle), 104 | # FIXME: sort field doesn't work doesn't work 105 | sort_field=str(await leetcode_data.freq_bar(leetcode_task_handle)).zfill(3), 106 | ) 107 | 108 | 109 | async def generate( 110 | start: int, stop: int, page_size: int, list_id: str, output_file: str 111 | ) -> None: 112 | """ 113 | Generate an Anki deck 114 | """ 115 | leetcode_model = genanki.Model( 116 | LEETCODE_ANKI_MODEL_ID, 117 | "Leetcode model", 118 | fields=[ 119 | {"name": "Slug"}, 120 | {"name": "Id"}, 121 | {"name": "Title"}, 122 | {"name": "Topic"}, 123 | {"name": "Content"}, 124 | {"name": "Difficulty"}, 125 | {"name": "Paid"}, 126 | {"name": "Likes"}, 127 | {"name": "Dislikes"}, 128 | {"name": "SubmissionsTotal"}, 129 | {"name": "SubmissionsAccepted"}, 130 | {"name": "SumissionAcceptRate"}, 131 | {"name": "Frequency"}, 132 | # TODO: add hints 133 | ], 134 | templates=[ 135 | { 136 | "name": "Leetcode", 137 | "qfmt": """ 138 |

{{Id}}. {{Title}}

139 | Difficulty: {{Difficulty}}
140 | 👍 {{Likes}} 👎 {{Dislikes}}
141 | Submissions (total/accepted): 142 | {{SubmissionsTotal}}/{{SubmissionsAccepted}} 143 | ({{SumissionAcceptRate}}%) 144 |
145 | Topic: {{Topic}}
146 | Frequency: 147 | 148 | {{Frequency}}% 149 | 150 |
151 | URL: 152 | 153 | https://leetcode.com/problems/{{Slug}}/ 154 | 155 |
156 |

Description

157 | {{Content}} 158 | """, 159 | "afmt": """ 160 | {{FrontSide}} 161 |
162 | Discuss URL: 163 | 164 | https://leetcode.com/problems/{{Slug}}/discuss/ 165 | 166 |
167 | Solution URL: 168 | 169 | https://leetcode.com/problems/{{Slug}}/solution/ 170 | 171 |
172 | """, 173 | } 174 | ], 175 | ) 176 | leetcode_deck = genanki.Deck(LEETCODE_ANKI_DECK_ID, Path(output_file).stem) 177 | 178 | leetcode_data = leetcode_anki.helpers.leetcode.LeetcodeData( 179 | start, stop, page_size, list_id 180 | ) 181 | 182 | note_generators: List[Awaitable[LeetcodeNote]] = [] 183 | 184 | task_handles = await leetcode_data.all_problems_handles() 185 | 186 | logging.info("Generating flashcards") 187 | for leetcode_task_handle in task_handles: 188 | note_generators.append( 189 | generate_anki_note(leetcode_data, leetcode_model, leetcode_task_handle) 190 | ) 191 | 192 | for leetcode_note in tqdm(note_generators, unit="flashcard"): 193 | leetcode_deck.add_note(await leetcode_note) 194 | 195 | genanki.Package(leetcode_deck).write_to_file(output_file) 196 | 197 | 198 | async def main() -> None: 199 | """ 200 | The main script logic 201 | """ 202 | args = parse_args() 203 | 204 | start, stop, page_size, list_id, output_file = ( 205 | args.start, 206 | args.stop, 207 | args.page_size, 208 | args.list_id, 209 | args.output_file, 210 | ) 211 | await generate(start, stop, page_size, list_id, output_file) 212 | 213 | 214 | if __name__ == "__main__": 215 | loop: asyncio.events.AbstractEventLoop = asyncio.get_event_loop() 216 | loop.run_until_complete(main()) 217 | -------------------------------------------------------------------------------- /leetcode_anki/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fspv/leetcode-anki/862c7ce0ae3bb5c09d89cf401bf238c40b203b40/leetcode_anki/__init__.py -------------------------------------------------------------------------------- /leetcode_anki/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fspv/leetcode-anki/862c7ce0ae3bb5c09d89cf401bf238c40b203b40/leetcode_anki/helpers/__init__.py -------------------------------------------------------------------------------- /leetcode_anki/helpers/leetcode.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-module-docstring 2 | import functools 3 | import json 4 | import logging 5 | import math 6 | import os 7 | import time 8 | from functools import cached_property 9 | from typing import Any, Callable, Dict, List, Tuple, Type, TypeVar 10 | 11 | # https://github.com/prius/python-leetcode 12 | import leetcode.api.default_api # type: ignore 13 | import leetcode.api_client # type: ignore 14 | import leetcode.auth # type: ignore 15 | import leetcode.configuration # type: ignore 16 | import leetcode.models.graphql_query # type: ignore 17 | import leetcode.models.graphql_query_get_question_detail_variables # type: ignore 18 | import leetcode.models.graphql_query_problemset_question_list_variables # type: ignore 19 | import leetcode.models.graphql_query_problemset_question_list_variables_filter_input # type: ignore 20 | import leetcode.models.graphql_question_detail # type: ignore 21 | import urllib3 # type: ignore 22 | from tqdm import tqdm # type: ignore 23 | 24 | CACHE_DIR = "cache" 25 | 26 | 27 | def _get_leetcode_api_client() -> leetcode.api.default_api.DefaultApi: 28 | """ 29 | Leetcode API instance constructor. 30 | 31 | This is a singleton, because we don't need to create a separate client 32 | each time 33 | """ 34 | 35 | configuration = leetcode.configuration.Configuration() 36 | 37 | session_id = os.environ["LEETCODE_SESSION_ID"] 38 | csrf_token = os.environ["LEETCODE_CSRF_TOKEN"] 39 | 40 | configuration.api_key["x-csrftoken"] = csrf_token 41 | configuration.api_key["csrftoken"] = csrf_token 42 | configuration.api_key["LEETCODE_SESSION"] = session_id 43 | configuration.api_key["Referer"] = "https://leetcode.com" 44 | configuration.debug = False 45 | api_instance = leetcode.api.default_api.DefaultApi( 46 | leetcode.api_client.ApiClient(configuration) 47 | ) 48 | 49 | return api_instance 50 | 51 | 52 | _T = TypeVar("_T") 53 | 54 | 55 | class _RetryDecorator: 56 | _times: int 57 | _exceptions: Tuple[Type[Exception]] 58 | _delay: float 59 | 60 | def __init__( 61 | self, times: int, exceptions: Tuple[Type[Exception]], delay: float 62 | ) -> None: 63 | self._times = times 64 | self._exceptions = exceptions 65 | self._delay = delay 66 | 67 | def __call__(self, func: Callable[..., _T]) -> Callable[..., _T]: 68 | times: int = self._times 69 | exceptions: Tuple[Type[Exception]] = self._exceptions 70 | delay: float = self._delay 71 | 72 | @functools.wraps(func) 73 | def wrapper(*args: Any, **kwargs: Any) -> _T: 74 | for attempt in range(times - 1): 75 | try: 76 | return func(*args, **kwargs) 77 | except exceptions: 78 | logging.exception( 79 | "Exception occured, try %s/%s", attempt + 1, times 80 | ) 81 | time.sleep(delay) 82 | 83 | logging.error("Last try") 84 | return func(*args, **kwargs) 85 | 86 | return wrapper 87 | 88 | 89 | def retry( 90 | times: int, exceptions: Tuple[Type[Exception]], delay: float 91 | ) -> _RetryDecorator: 92 | """ 93 | Retry Decorator 94 | Retries the wrapped function/method `times` times if the exceptions listed 95 | in `exceptions` are thrown 96 | """ 97 | 98 | return _RetryDecorator(times, exceptions, delay) 99 | 100 | 101 | class LeetcodeData: 102 | """ 103 | Retrieves and caches the data for problems, acquired from the leetcode API. 104 | 105 | This data can be later accessed using provided methods with corresponding 106 | names. 107 | """ 108 | 109 | def __init__( 110 | self, start: int, stop: int, page_size: int = 1000, list_id: str = "" 111 | ) -> None: 112 | """ 113 | Initialize leetcode API and disk cache for API responses 114 | """ 115 | if start < 0: 116 | raise ValueError(f"Start must be non-negative: {start}") 117 | 118 | if stop < 0: 119 | raise ValueError(f"Stop must be non-negative: {start}") 120 | 121 | if page_size < 0: 122 | raise ValueError(f"Page size must be greater than 0: {page_size}") 123 | 124 | if start > stop: 125 | raise ValueError(f"Start (){start}) must be not greater than stop ({stop})") 126 | 127 | self._start = start 128 | self._stop = stop 129 | self._page_size = page_size 130 | self._list_id = list_id 131 | 132 | @cached_property 133 | def _api_instance(self) -> leetcode.api.default_api.DefaultApi: 134 | return _get_leetcode_api_client() 135 | 136 | @cached_property 137 | def _cache( 138 | self, 139 | ) -> Dict[str, leetcode.models.graphql_question_detail.GraphqlQuestionDetail]: 140 | """ 141 | Cached method to return dict (problem_slug -> question details) 142 | """ 143 | problems = self._get_problems_data() 144 | return {problem.title_slug: problem for problem in problems} 145 | 146 | @retry(times=3, exceptions=(urllib3.exceptions.ProtocolError,), delay=5) 147 | def _get_problems_count(self) -> int: 148 | api_instance = self._api_instance 149 | 150 | graphql_request = leetcode.models.graphql_query.GraphqlQuery( 151 | query=""" 152 | query problemsetQuestionList($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) { 153 | problemsetQuestionList: questionList( 154 | categorySlug: $categorySlug 155 | limit: $limit 156 | skip: $skip 157 | filters: $filters 158 | ) { 159 | totalNum 160 | } 161 | } 162 | """, 163 | variables=leetcode.models.graphql_query_problemset_question_list_variables.GraphqlQueryProblemsetQuestionListVariables( 164 | category_slug="", 165 | limit=1, 166 | skip=0, 167 | filters=leetcode.models.graphql_query_problemset_question_list_variables_filter_input.GraphqlQueryProblemsetQuestionListVariablesFilterInput( 168 | tags=[], 169 | list_id=self._list_id, 170 | # difficulty="MEDIUM", 171 | # status="NOT_STARTED", 172 | # list_id="7p5x763", # Top Amazon Questions 173 | # premium_only=False, 174 | ), 175 | ), 176 | operation_name="problemsetQuestionList", 177 | ) 178 | 179 | time.sleep(2) # Leetcode has a rate limiter 180 | data = api_instance.graphql_post(body=graphql_request).data 181 | 182 | return data.problemset_question_list.total_num or 0 183 | 184 | @retry(times=3, exceptions=(urllib3.exceptions.ProtocolError,), delay=5) 185 | def _get_problems_data_page( 186 | self, offset: int, page_size: int, page: int 187 | ) -> List[leetcode.models.graphql_question_detail.GraphqlQuestionDetail]: 188 | api_instance = self._api_instance 189 | graphql_request = leetcode.models.graphql_query.GraphqlQuery( 190 | query=""" 191 | query problemsetQuestionList($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) { 192 | problemsetQuestionList: questionList( 193 | categorySlug: $categorySlug 194 | limit: $limit 195 | skip: $skip 196 | filters: $filters 197 | ) { 198 | questions: data { 199 | questionFrontendId 200 | title 201 | titleSlug 202 | categoryTitle 203 | freqBar 204 | content 205 | isPaidOnly 206 | difficulty 207 | likes 208 | dislikes 209 | topicTags { 210 | name 211 | slug 212 | } 213 | stats 214 | hints 215 | } 216 | } 217 | } 218 | """, 219 | variables=leetcode.models.graphql_query_problemset_question_list_variables.GraphqlQueryProblemsetQuestionListVariables( 220 | category_slug="", 221 | limit=page_size, 222 | skip=offset + page * page_size, 223 | filters=leetcode.models.graphql_query_problemset_question_list_variables_filter_input.GraphqlQueryProblemsetQuestionListVariablesFilterInput( 224 | list_id=self._list_id 225 | ), 226 | ), 227 | operation_name="problemsetQuestionList", 228 | ) 229 | 230 | time.sleep(2) # Leetcode has a rate limiter 231 | data = api_instance.graphql_post( 232 | body=graphql_request 233 | ).data.problemset_question_list.questions 234 | 235 | return data 236 | 237 | def _get_problems_data( 238 | self, 239 | ) -> List[leetcode.models.graphql_question_detail.GraphqlQuestionDetail]: 240 | problem_count = self._get_problems_count() 241 | 242 | if self._start > problem_count: 243 | raise ValueError( 244 | "Start ({self._start}) is greater than problems count ({problem_count})" 245 | ) 246 | 247 | start = self._start 248 | stop = min(self._stop, problem_count) 249 | 250 | page_size = min(self._page_size, stop - start + 1) 251 | 252 | problems: List[ 253 | leetcode.models.graphql_question_detail.GraphqlQuestionDetail 254 | ] = [] 255 | 256 | logging.info("Fetching %s problems %s per page", stop - start + 1, page_size) 257 | 258 | for page in tqdm( 259 | range(math.ceil((stop - start + 1) / page_size)), 260 | unit="problem", 261 | unit_scale=page_size, 262 | ): 263 | data = self._get_problems_data_page(start, page_size, page) 264 | problems.extend(data) 265 | 266 | return problems 267 | 268 | async def all_problems_handles(self) -> List[str]: 269 | """ 270 | Get all problem handles known. 271 | 272 | Example: ["two-sum", "three-sum"] 273 | """ 274 | return list(self._cache.keys()) 275 | 276 | def _get_problem_data( 277 | self, problem_slug: str 278 | ) -> leetcode.models.graphql_question_detail.GraphqlQuestionDetail: 279 | """ 280 | TODO: Legacy method. Needed in the old architecture. Can be replaced 281 | with direct cache calls later. 282 | """ 283 | cache = self._cache 284 | if problem_slug in cache: 285 | return cache[problem_slug] 286 | 287 | raise ValueError(f"Problem {problem_slug} is not in cache") 288 | 289 | async def _get_description(self, problem_slug: str) -> str: 290 | """ 291 | Problem description 292 | """ 293 | data = self._get_problem_data(problem_slug) 294 | return data.content or "No content" 295 | 296 | async def _stats(self, problem_slug: str) -> Dict[str, str]: 297 | """ 298 | Various stats about problem. Such as number of accepted solutions, etc. 299 | """ 300 | data = self._get_problem_data(problem_slug) 301 | return json.loads(data.stats) 302 | 303 | async def submissions_total(self, problem_slug: str) -> int: 304 | """ 305 | Total number of submissions of the problem 306 | """ 307 | return int((await self._stats(problem_slug))["totalSubmissionRaw"]) 308 | 309 | async def submissions_accepted(self, problem_slug: str) -> int: 310 | """ 311 | Number of accepted submissions of the problem 312 | """ 313 | return int((await self._stats(problem_slug))["totalAcceptedRaw"]) 314 | 315 | async def description(self, problem_slug: str) -> str: 316 | """ 317 | Problem description 318 | """ 319 | return await self._get_description(problem_slug) 320 | 321 | async def difficulty(self, problem_slug: str) -> str: 322 | """ 323 | Problem difficulty. Returns colored HTML version, so it can be used 324 | directly in Anki 325 | """ 326 | data = self._get_problem_data(problem_slug) 327 | diff = data.difficulty 328 | 329 | if diff == "Easy": 330 | return "Easy" 331 | 332 | if diff == "Medium": 333 | return "Medium" 334 | 335 | if diff == "Hard": 336 | return "Hard" 337 | 338 | raise ValueError(f"Incorrect difficulty: {diff}") 339 | 340 | async def paid(self, problem_slug: str) -> str: 341 | """ 342 | Problem's "available for paid subsribers" status 343 | """ 344 | data = self._get_problem_data(problem_slug) 345 | return data.is_paid_only 346 | 347 | async def problem_id(self, problem_slug: str) -> str: 348 | """ 349 | Numerical id of the problem 350 | """ 351 | data = self._get_problem_data(problem_slug) 352 | return data.question_frontend_id 353 | 354 | async def likes(self, problem_slug: str) -> int: 355 | """ 356 | Number of likes for the problem 357 | """ 358 | data = self._get_problem_data(problem_slug) 359 | likes = data.likes 360 | 361 | if not isinstance(likes, int): 362 | raise ValueError(f"Likes should be int: {likes}") 363 | 364 | return likes 365 | 366 | async def dislikes(self, problem_slug: str) -> int: 367 | """ 368 | Number of dislikes for the problem 369 | """ 370 | data = self._get_problem_data(problem_slug) 371 | dislikes = data.dislikes 372 | 373 | if not isinstance(dislikes, int): 374 | raise ValueError(f"Dislikes should be int: {dislikes}") 375 | 376 | return dislikes 377 | 378 | async def tags(self, problem_slug: str) -> List[str]: 379 | """ 380 | List of the tags for this problem (string slugs) 381 | """ 382 | data = self._get_problem_data(problem_slug) 383 | tags = list(map(lambda x: x.slug, data.topic_tags)) 384 | tags.append(f"difficulty-{data.difficulty.lower()}-tag") 385 | return tags 386 | 387 | async def freq_bar(self, problem_slug: str) -> float: 388 | """ 389 | Returns percentage for frequency bar 390 | """ 391 | data = self._get_problem_data(problem_slug) 392 | return data.freq_bar or 0 393 | 394 | async def title(self, problem_slug: str) -> float: 395 | """ 396 | Returns problem title 397 | """ 398 | data = self._get_problem_data(problem_slug) 399 | return data.title 400 | 401 | async def category(self, problem_slug: str) -> float: 402 | """ 403 | Returns problem category title 404 | """ 405 | data = self._get_problem_data(problem_slug) 406 | return data.category_title 407 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pytest.ini_options] 2 | asyncio_mode = "strict" 3 | testpaths = [ 4 | "test", 5 | ] 6 | 7 | [tool.pylint] 8 | max-line-length = 88 9 | disable = ["line-too-long"] 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-leetcode==1.2.1 2 | setuptools==57.5.0 3 | genanki 4 | tqdm 5 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-asyncio 3 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fspv/leetcode-anki/862c7ce0ae3bb5c09d89cf401bf238c40b203b40/test/__init__.py -------------------------------------------------------------------------------- /test/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fspv/leetcode-anki/862c7ce0ae3bb5c09d89cf401bf238c40b203b40/test/helpers/__init__.py -------------------------------------------------------------------------------- /test/helpers/test_leetcode.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional 2 | from unittest import mock 3 | 4 | import leetcode.models.graphql_data # type: ignore 5 | import leetcode.models.graphql_problemset_question_list # type: ignore 6 | import leetcode.models.graphql_question_contributor # type: ignore 7 | import leetcode.models.graphql_question_detail # type: ignore 8 | import leetcode.models.graphql_question_solution # type: ignore 9 | import leetcode.models.graphql_question_topic_tag # type: ignore 10 | import leetcode.models.graphql_response # type: ignore 11 | import leetcode.models.problems # type: ignore 12 | import leetcode.models.stat # type: ignore 13 | import leetcode.models.stat_status_pair # type: ignore 14 | import pytest 15 | 16 | import leetcode_anki.helpers.leetcode 17 | 18 | QUESTION_DETAIL = leetcode.models.graphql_question_detail.GraphqlQuestionDetail( 19 | freq_bar=1.1, 20 | question_id="1", 21 | question_frontend_id="1", 22 | bound_topic_id=1, 23 | title="test title", 24 | title_slug="test", 25 | content="test content", 26 | translated_title="test", 27 | translated_content="test translated content", 28 | is_paid_only=False, 29 | difficulty="Hard", 30 | likes=1, 31 | dislikes=1, 32 | is_liked=False, 33 | similar_questions="{}", 34 | contributors=[ 35 | leetcode.models.graphql_question_contributor.GraphqlQuestionContributor( 36 | username="testcontributor", 37 | profile_url="test://profile/url", 38 | avatar_url="test://avatar/url", 39 | ) 40 | ], 41 | lang_to_valid_playground="{}", 42 | topic_tags=[ 43 | leetcode.models.graphql_question_topic_tag.GraphqlQuestionTopicTag( 44 | name="test tag", 45 | slug="test-tag", 46 | translated_name="translated test tag", 47 | typename="test type name", 48 | ) 49 | ], 50 | company_tag_stats="{}", 51 | code_snippets="{}", 52 | stats='{"totalSubmissionRaw": 1, "totalAcceptedRaw": 1}', 53 | hints=["test hint 1", "test hint 2"], 54 | solution=[ 55 | leetcode.models.graphql_question_solution.GraphqlQuestionSolution( 56 | id=1, can_see_detail=False, typename="test type name" 57 | ) 58 | ], 59 | status="ac", 60 | sample_test_case="test case", 61 | meta_data="{}", 62 | judger_available=False, 63 | judge_type="large", 64 | mysql_schemas="test schema", 65 | enable_run_code=False, 66 | enable_test_mode=False, 67 | env_info="{}", 68 | ) 69 | 70 | 71 | def dummy_return_question_detail_dict( 72 | question_detail: leetcode.models.graphql_question_detail.GraphqlQuestionDetail, 73 | ) -> Dict[str, leetcode.models.graphql_question_detail.GraphqlQuestionDetail]: 74 | return {"test": question_detail} 75 | 76 | 77 | @mock.patch("os.environ", mock.MagicMock(return_value={"LEETCODE_SESSION_ID": "test"})) 78 | @mock.patch("os.environ", mock.MagicMock(return_value={"LEETCODE_CSRF_TOKEN": "test"})) 79 | @mock.patch("leetcode.auth", mock.MagicMock()) 80 | class TestLeetcode: 81 | # pyre-fixme[56]: Pyre was not able to infer the type of the decorator 82 | # `pytest.mark.asyncio`. 83 | @pytest.mark.asyncio 84 | async def test_get_leetcode_api_client(self) -> None: 85 | assert leetcode_anki.helpers.leetcode._get_leetcode_api_client() 86 | 87 | # pyre-fixme[56]: Pyre was not able to infer the type of the decorator 88 | # `pytest.mark.asyncio`. 89 | @pytest.mark.asyncio 90 | async def test_retry(self) -> None: 91 | decorator = leetcode_anki.helpers.leetcode.retry( 92 | times=3, exceptions=(RuntimeError,), delay=0.01 93 | ) 94 | 95 | async def test() -> str: 96 | return "test" 97 | 98 | func = mock.Mock(side_effect=[RuntimeError, RuntimeError, test()]) 99 | 100 | wrapper = decorator(func) 101 | 102 | assert (await wrapper()) == "test" 103 | 104 | assert func.call_count == 3 105 | 106 | 107 | @mock.patch("leetcode_anki.helpers.leetcode._get_leetcode_api_client", mock.Mock()) 108 | class TestLeetcodeData: 109 | _question_detail_singleton: Optional[ 110 | leetcode.models.graphql_question_detail.GraphqlQuestionDetail 111 | ] = None 112 | _leetcode_data_singleton: Optional[leetcode_anki.helpers.leetcode.LeetcodeData] = ( 113 | None 114 | ) 115 | 116 | @property 117 | def _question_details( 118 | self, 119 | ) -> leetcode.models.graphql_question_detail.GraphqlQuestionDetail: 120 | question_detail = self._question_detail_singleton 121 | 122 | if not question_detail: 123 | raise ValueError("Question detail must not be None") 124 | 125 | return question_detail 126 | 127 | @property 128 | def _leetcode_data(self) -> leetcode_anki.helpers.leetcode.LeetcodeData: 129 | leetcode_data = self._leetcode_data_singleton 130 | 131 | if not leetcode_data: 132 | raise ValueError("Leetcode data must not be None") 133 | 134 | return leetcode_data 135 | 136 | def setup_method(self) -> None: 137 | self._question_detail_singleton = QUESTION_DETAIL 138 | self._leetcode_data_singleton = leetcode_anki.helpers.leetcode.LeetcodeData( 139 | 0, 10000 140 | ) 141 | 142 | # pyre-fixme[56]: Pyre was not able to infer the type of the decorator 143 | # `pytest.mark.asyncio`. 144 | @pytest.mark.asyncio 145 | @mock.patch( 146 | "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", 147 | mock.Mock(return_value=[QUESTION_DETAIL]), 148 | ) 149 | async def test_init(self) -> None: 150 | self._leetcode_data._cache["test"] = QUESTION_DETAIL 151 | 152 | # pyre-fixme[56]: Pyre was not able to infer the type of the decorator 153 | # `pytest.mark.asyncio`. 154 | @pytest.mark.asyncio 155 | @mock.patch( 156 | "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", 157 | mock.Mock(return_value=[QUESTION_DETAIL]), 158 | ) 159 | async def test_get_description(self) -> None: 160 | self._leetcode_data._cache["test"] = QUESTION_DETAIL 161 | assert (await self._leetcode_data.description("test")) == "test content" 162 | 163 | # pyre-fixme[56]: Pyre was not able to infer the type of the decorator 164 | # `pytest.mark.asyncio`. 165 | @pytest.mark.asyncio 166 | @mock.patch( 167 | "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", 168 | mock.Mock(return_value=[QUESTION_DETAIL]), 169 | ) 170 | async def test_submissions(self) -> None: 171 | self._leetcode_data._cache["test"] = QUESTION_DETAIL 172 | assert (await self._leetcode_data.description("test")) == "test content" 173 | assert (await self._leetcode_data.submissions_total("test")) == 1 174 | assert (await self._leetcode_data.submissions_accepted("test")) == 1 175 | 176 | # pyre-fixme[56]: Pyre was not able to infer the type of the decorator 177 | # `pytest.mark.asyncio`. 178 | @pytest.mark.asyncio 179 | @mock.patch( 180 | "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", 181 | mock.Mock(return_value=[QUESTION_DETAIL]), 182 | ) 183 | async def test_difficulty_easy(self) -> None: 184 | self._leetcode_data._cache["test"] = QUESTION_DETAIL 185 | 186 | QUESTION_DETAIL.difficulty = "Easy" 187 | assert "Easy" in (await self._leetcode_data.difficulty("test")) 188 | 189 | # pyre-fixme[56]: Pyre was not able to infer the type of the decorator 190 | # `pytest.mark.asyncio`. 191 | @pytest.mark.asyncio 192 | @mock.patch( 193 | "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", 194 | mock.Mock(return_value=[QUESTION_DETAIL]), 195 | ) 196 | async def test_difficulty_medium(self) -> None: 197 | self._leetcode_data._cache["test"] = QUESTION_DETAIL 198 | 199 | QUESTION_DETAIL.difficulty = "Medium" 200 | assert "Medium" in (await self._leetcode_data.difficulty("test")) 201 | 202 | # pyre-fixme[56]: Pyre was not able to infer the type of the decorator 203 | # `pytest.mark.asyncio`. 204 | @pytest.mark.asyncio 205 | @mock.patch( 206 | "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", 207 | mock.Mock(return_value=[QUESTION_DETAIL]), 208 | ) 209 | async def test_difficulty_hard(self) -> None: 210 | self._leetcode_data._cache["test"] = QUESTION_DETAIL 211 | 212 | QUESTION_DETAIL.difficulty = "Hard" 213 | assert "Hard" in (await self._leetcode_data.difficulty("test")) 214 | 215 | # pyre-fixme[56]: Pyre was not able to infer the type of the decorator 216 | # `pytest.mark.asyncio`. 217 | @pytest.mark.asyncio 218 | @mock.patch( 219 | "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", 220 | mock.Mock(return_value=[QUESTION_DETAIL]), 221 | ) 222 | async def test_paid(self) -> None: 223 | self._leetcode_data._cache["test"] = QUESTION_DETAIL 224 | 225 | assert (await self._leetcode_data.paid("test")) is False 226 | 227 | # pyre-fixme[56]: Pyre was not able to infer the type of the decorator 228 | # `pytest.mark.asyncio`. 229 | @pytest.mark.asyncio 230 | @mock.patch( 231 | "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", 232 | mock.Mock(return_value=[QUESTION_DETAIL]), 233 | ) 234 | async def test_problem_id(self) -> None: 235 | self._leetcode_data._cache["test"] = QUESTION_DETAIL 236 | 237 | assert (await self._leetcode_data.problem_id("test")) == "1" 238 | 239 | # pyre-fixme[56]: Pyre was not able to infer the type of the decorator 240 | # `pytest.mark.asyncio`. 241 | @pytest.mark.asyncio 242 | @mock.patch( 243 | "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", 244 | mock.Mock(return_value=[QUESTION_DETAIL]), 245 | ) 246 | async def test_likes(self) -> None: 247 | self._leetcode_data._cache["test"] = QUESTION_DETAIL 248 | 249 | assert (await self._leetcode_data.likes("test")) == 1 250 | 251 | # pyre-fixme[56]: Pyre was not able to infer the type of the decorator 252 | # `pytest.mark.asyncio`. 253 | @pytest.mark.asyncio 254 | @mock.patch( 255 | "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", 256 | mock.Mock(return_value=[QUESTION_DETAIL]), 257 | ) 258 | async def test_dislikes(self) -> None: 259 | self._leetcode_data._cache["test"] = QUESTION_DETAIL 260 | 261 | assert (await self._leetcode_data.dislikes("test")) == 1 262 | 263 | # pyre-fixme[56]: Pyre was not able to infer the type of the decorator 264 | # `pytest.mark.asyncio`. 265 | @pytest.mark.asyncio 266 | @mock.patch( 267 | "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", 268 | mock.Mock(return_value=[QUESTION_DETAIL]), 269 | ) 270 | async def test_tags(self) -> None: 271 | self._leetcode_data._cache["test"] = QUESTION_DETAIL 272 | 273 | assert (await self._leetcode_data.tags("test")) == [ 274 | "test-tag", 275 | "difficulty-hard-tag", 276 | ] 277 | 278 | # pyre-fixme[56]: Pyre was not able to infer the type of the decorator 279 | # `pytest.mark.asyncio`. 280 | @pytest.mark.asyncio 281 | @mock.patch( 282 | "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", 283 | mock.Mock(return_value=[QUESTION_DETAIL]), 284 | ) 285 | async def test_freq_bar(self) -> None: 286 | self._leetcode_data._cache["test"] = QUESTION_DETAIL 287 | 288 | assert (await self._leetcode_data.freq_bar("test")) == 1.1 289 | 290 | # pyre-fixme[56]: Pyre was not able to infer the type of the decorator 291 | # `pytest.mark.asyncio`. 292 | @pytest.mark.asyncio 293 | @mock.patch( 294 | "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data", 295 | mock.Mock(return_value=[QUESTION_DETAIL]), 296 | ) 297 | async def test_get_problem_data(self) -> None: 298 | assert self._leetcode_data._cache["test"] == QUESTION_DETAIL 299 | 300 | @mock.patch("time.sleep", mock.Mock()) 301 | # pyre-fixme[56]: Pyre was not able to infer the type of the decorator 302 | # `pytest.mark.asyncio`. 303 | @pytest.mark.asyncio 304 | async def test_get_problems_data_page(self) -> None: 305 | data = leetcode.models.graphql_data.GraphqlData( 306 | problemset_question_list=leetcode.models.graphql_problemset_question_list.GraphqlProblemsetQuestionList( 307 | questions=[QUESTION_DETAIL], total_num=1 308 | ) 309 | ) 310 | response = leetcode.models.graphql_response.GraphqlResponse(data=data) 311 | self._leetcode_data._api_instance.graphql_post.return_value = response 312 | 313 | assert self._leetcode_data._get_problems_data_page(0, 10, 0) == [ 314 | QUESTION_DETAIL 315 | ] 316 | 317 | # pyre-fixme[56]: Pyre was not able to infer the type of the decorator 318 | # `pytest.mark.asyncio`. 319 | @pytest.mark.asyncio 320 | @mock.patch( 321 | "leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_count", 322 | mock.Mock(return_value=234), 323 | ) 324 | @mock.patch("leetcode_anki.helpers.leetcode.LeetcodeData._get_problems_data_page") 325 | async def test_get_problems_data( 326 | self, mock_get_problems_data_page: mock.Mock 327 | ) -> None: 328 | question_list: List[ 329 | leetcode.models.graphql_question_detail.GraphqlQuestionDetail 330 | ] = [QUESTION_DETAIL] * 234 331 | 332 | def dummy( 333 | offset: int, page_size: int, page: int 334 | ) -> List[leetcode.models.graphql_question_detail.GraphqlQuestionDetail]: 335 | return [ 336 | question_list.pop() for _ in range(min(page_size, len(question_list))) 337 | ] 338 | 339 | mock_get_problems_data_page.side_effect = dummy 340 | 341 | assert len(self._leetcode_data._get_problems_data()) == 234 342 | -------------------------------------------------------------------------------- /test/test_dummy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Just a placeholder 3 | """ 4 | 5 | 6 | class TestDummy: 7 | """ 8 | Dummy test 9 | """ 10 | 11 | @staticmethod 12 | def test_do_nothing() -> None: 13 | """Do nothing""" 14 | assert True 15 | 16 | @staticmethod 17 | def test_do_nothing2() -> None: 18 | """Do nothing""" 19 | assert True 20 | --------------------------------------------------------------------------------