├── tests ├── utils │ ├── __init__.py │ ├── utils.py │ └── response_mocks.py ├── test_mixins.py ├── test_client.py ├── test_url_builder.py ├── test_wanikani_api.py └── live_api_test.py ├── docs ├── authors.rst ├── history.rst ├── readme.rst ├── contributing.rst ├── client.rst ├── exceptions.rst ├── datatypes.rst ├── index.rst ├── Makefile ├── make.bat ├── installation.rst ├── usage.rst └── conf.py ├── .pre-commit-config.yaml ├── wanikani_api ├── __init__.py ├── exceptions.py ├── constants.py ├── subjectcache.py ├── url_builder.py ├── client.py └── models.py ├── AUTHORS.rst ├── requirements_test.txt ├── MANIFEST.in ├── requirements_dev.txt ├── .editorconfig ├── .github └── ISSUE_TEMPLATE.md ├── setup.cfg ├── tox.ini ├── .travis.yml ├── HISTORY.rst ├── setup.py ├── LICENSE ├── .gitignore ├── Makefile ├── README.rst └── CONTRIBUTING.rst /tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/client.rst: -------------------------------------------------------------------------------- 1 | Client Module 2 | ===================== 3 | .. automodule:: wanikani_api.client 4 | :members: 5 | -------------------------------------------------------------------------------- /docs/exceptions.rst: -------------------------------------------------------------------------------- 1 | Exceptions 2 | ===================== 3 | .. automodule:: wanikani_api.exceptions 4 | :members: 5 | -------------------------------------------------------------------------------- /docs/datatypes.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Datatypes 3 | ========= 4 | .. automodule:: wanikani_api.models 5 | :members: 6 | 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/ambv/black 3 | rev: 18.6b4 4 | hooks: 5 | - id: black -------------------------------------------------------------------------------- /wanikani_api/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Top-level package for wanikani_api.""" 4 | 5 | __author__ = """Gary Grant Graham""" 6 | __email__ = "gary@kaniwani.com" 7 | __version__ = "__version__ = '0.6.1'" 8 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Gary Grant Graham 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | pip==19.1.1 2 | bumpversion==0.5.3 3 | Sphinx==1.8.3 4 | wheel==0.33.4 5 | coverage==4.5.2 6 | tox==3.10.0 7 | flake8==3.6.0 8 | watchdog==0.9.0 9 | twine==1.12.1 10 | 11 | pytest==4.5.0 12 | pytest-runner==4.2 13 | pytest-mock==1.10.0 14 | requests-mock==1.6.0 15 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | 7 | recursive-include tests * 8 | recursive-exclude * __pycache__ 9 | recursive-exclude * *.py[co] 10 | 11 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 12 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pip==19.1.1 2 | bumpversion==0.5.3 3 | Sphinx==1.8.3 4 | wheel==0.33.4 5 | coverage==4.5.2 6 | tox==3.10.0 7 | flake8==3.6.0 8 | watchdog==0.10.2 9 | twine==1.12.1 10 | 11 | pytest==4.5.0 12 | pytest-runner==4.2 13 | pytest-mock==1.10.0 14 | 15 | black==19.3b0 16 | sphinx_rtd_theme==0.4.2 17 | build 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * wanikani_api version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /wanikani_api/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidWanikaniApiKeyException(Exception): 2 | """ 3 | The client was initialized with an invalid V2 API key, causing 4 | Wanikani to return a ``401 unauthorized`` response. 5 | """ 6 | 7 | pass 8 | 9 | 10 | class UnknownResourceException(Exception): 11 | """ 12 | The model factory was unable to determine what type of resource 13 | Wanikani is sending back, or is not familiar with it. 14 | """ 15 | 16 | pass 17 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.6.1 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:wanikani_api/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [bdist_wheel] 15 | universal = 1 16 | 17 | [flake8] 18 | exclude = docs 19 | 20 | [aliases] 21 | test = pytest 22 | 23 | [tool:pytest] 24 | collect_ignore = ['setup.py'] 25 | 26 | -------------------------------------------------------------------------------- /wanikani_api/constants.py: -------------------------------------------------------------------------------- 1 | 2 | ROOT_WK_API_URL = "https://api.wanikani.com/v2/" 3 | RESOURCES_WITHOUT_IDS = ["user", "collection", "report"] 4 | SUBJECT_ENDPOINT = "subjects" 5 | SINGLE_SUBJECT_ENPOINT = r"subjects/\d+" 6 | ASSIGNMENT_ENDPOINT = "assignments" 7 | REVIEW_STATS_ENDPOINT = "review_statistics" 8 | STUDY_MATERIALS_ENDPOINT = "study_materials" 9 | REVIEWS_ENDPOINT = "reviews" 10 | LEVEL_PROGRESSIONS_ENDPOINT = "level_progressions" 11 | RESETS_ENDPOINT = "resets" 12 | SUMMARY_ENDPOINT = "summary" 13 | USER_ENDPOINT = "user" 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36, py37, flake8 3 | 4 | [travis] 5 | python = 6 | 3.6: py36 7 | 3.7: py37 8 | 9 | [flake8] 10 | ignore = E501, W503 11 | 12 | [testenv:flake8] 13 | basepython = python 14 | deps = flake8 15 | commands = flake8 wanikani_api 16 | 17 | [testenv] 18 | setenv = 19 | PYTHONPATH = {toxinidir} 20 | deps = 21 | -r{toxinidir}/requirements_test.txt 22 | ; If you want to make tox run the tests with the same versions, create a 23 | ; requirements.txt with the pinned versions and uncomment the following line: 24 | ; -r{toxinidir}/requirements.txt 25 | commands = 26 | pip install -U pip 27 | py.test --basetemp={envtmpdir} 28 | 29 | 30 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | |wk_logo| wanikani_api 2 | ======================================== 3 | 4 | .. |wk_logo| image:: https://discourse-cdn-sjc1.com/business5/uploads/wanikani_community/original/3X/7/a/7a2bd7e8dcf8d7766b51a77960d86949215c830c.png?v=5 5 | :target: https://wanikani.com 6 | :width: 56 7 | :height: 56 8 | :align: middle 9 | 10 | .. toctree:: 11 | :maxdepth: 2 12 | :caption: Contents: 13 | 14 | readme 15 | installation 16 | usage 17 | client 18 | datatypes 19 | exceptions 20 | contributing 21 | authors 22 | history 23 | 24 | Indices and tables 25 | ================== 26 | * :ref:`genindex` 27 | * :ref:`modindex` 28 | * :ref:`search` 29 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = wanikani_api 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.org 2 | 3 | language: python 4 | python: 5 | - 3.7-dev 6 | - 3.6 7 | 8 | # Command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors 9 | install: pip install -U tox-travis 10 | 11 | # Command to run tests, e.g. python setup.py test 12 | script: tox 13 | 14 | # Assuming you have installed the travis-ci CLI tool, after you 15 | # create the Github repo and add it to Travis, run the 16 | # following command to finish PyPI deployment setup: 17 | # $ travis encrypt --add deploy.password 18 | deploy: 19 | provider: pypi 20 | distributions: sdist bdist_wheel 21 | user: Tadgh 22 | password: 23 | secure: PLEASE_REPLACE_ME 24 | on: 25 | tags: true 26 | repo: kaniwani/wanikani_api 27 | python: 3.6 28 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=wanikani_api 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /wanikani_api/subjectcache.py: -------------------------------------------------------------------------------- 1 | class SubjectCache: 2 | class __SubjectCache: 3 | def __init__(self, subjects): 4 | 5 | self._populate_cache(subjects) 6 | 7 | def get(self, subject_id): 8 | try: 9 | return self.cached_subjects[subject_id] 10 | except KeyError: 11 | raise KeyError( 12 | "We couldn't find a subject with ID {} in the cache!".format( 13 | subject_id 14 | ) 15 | ) 16 | 17 | def _populate_cache(self, subjects): 18 | self.cached_subjects = {subject.id: subject for subject in subjects} 19 | 20 | instance = None 21 | 22 | def __init__(self, subjects): 23 | if not SubjectCache.instance: 24 | SubjectCache.instance = SubjectCache.__SubjectCache(subjects) 25 | 26 | def __getattr__(self, name): 27 | return getattr(self.instance, name) 28 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | 0.5.0 (2020-08-18) 6 | ------------------ 7 | * Remove mock responses that included stage names. 8 | 9 | 10 | 0.5.0 (2020-08-18) 11 | ------------------ 12 | * Remove `passed` from resource. 13 | * Remove `srs_stage_name` from assignments and reviews 14 | 15 | 0.4.0 (2020-04-30) 16 | ------------------ 17 | * Add Preferences to User Information 18 | * Add Subscription to User Information 19 | 20 | 0.3.0 (2019-11-09) 21 | ------------------ 22 | * Add `auxiliary_meanings` to Subject 23 | 24 | 0.2.1 (2019-11-05) 25 | ------------------ 26 | * Fix crash caused by WK removing a field from their API. 27 | 28 | 0.1.1 (2018-06-26) 29 | ------------------ 30 | 31 | * Change Assignment endpoint to reflect the newly dropped fields from the api (`level` specifically). 32 | * Add some proper String representation 33 | * Work on the Etag cache, bringing it closer to completion. 34 | 35 | 0.1.0 (2018-06-26) 36 | ------------------ 37 | 38 | * First release on PyPI. 39 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | Stable release 9 | -------------- 10 | 11 | To install wanikani_api, run this command in your terminal: 12 | 13 | .. code-block:: console 14 | 15 | $ pip install wanikani_api 16 | 17 | This is the preferred method to install wanikani_api, as it will always install the most recent stable release. 18 | 19 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 20 | you through the process. 21 | 22 | .. _pip: https://pip.pypa.io 23 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 24 | 25 | 26 | From sources 27 | ------------ 28 | 29 | The sources for wanikani_api can be downloaded from the `Github repo`_. 30 | 31 | You can either clone the public repository: 32 | 33 | .. code-block:: console 34 | 35 | $ git clone git://github.com/Kaniwani/wanikani_api 36 | 37 | Or download the `tarball`_: 38 | 39 | .. code-block:: console 40 | 41 | $ curl -OL https://github.com/Kaniwani/wanikani_api/tarball/master 42 | 43 | Once you have a copy of the source, you can install it with: 44 | 45 | .. code-block:: console 46 | 47 | $ python setup.py install 48 | 49 | 50 | .. _Github repo: https://github.com/Kaniwani/wanikani_api 51 | .. _tarball: https://github.com/Kaniwani/wanikani_api/tarball/master 52 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """The setup script.""" 5 | 6 | from setuptools import setup, find_packages 7 | 8 | with open("README.rst") as readme_file: 9 | readme = readme_file.read() 10 | 11 | with open("HISTORY.rst") as history_file: 12 | history = history_file.read() 13 | 14 | requirements = ["requests", "python-dateutil"] 15 | 16 | setup_requirements = ["pytest-runner"] 17 | 18 | test_requirements = ["pytest"] 19 | 20 | setup( 21 | author="Gary Grant Graham", 22 | author_email="gary@kaniwani.com", 23 | classifiers=[ 24 | "Development Status :: 2 - Pre-Alpha", 25 | "Intended Audience :: Developers", 26 | "License :: OSI Approved :: BSD License", 27 | "Natural Language :: English", 28 | "Programming Language :: Python :: 3.6", 29 | "Programming Language :: Python :: 3.7", 30 | ], 31 | description="An API wrapper for Wanikani (V2)", 32 | install_requires=requirements, 33 | license="BSD license", 34 | long_description=readme + "\n\n" + history, 35 | include_package_data=True, 36 | keywords="wanikani_api", 37 | name="wanikani_api", 38 | packages=find_packages(include=["wanikani_api"]), 39 | setup_requires=setup_requirements, 40 | test_suite="tests", 41 | tests_require=test_requirements, 42 | url="https://github.com/Kaniwani/wanikani_api", 43 | version="0.6.1", 44 | zip_safe=False, 45 | ) 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | 3 | BSD License 4 | 5 | Copyright (c) 2018, Gary Grant Graham 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without modification, 9 | are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, this 12 | list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, this 15 | list of conditions and the following disclaimer in the documentation and/or 16 | other materials provided with the distribution. 17 | 18 | * Neither the name of the copyright holder nor the names of its 19 | contributors may be used to endorse or promote products derived from this 20 | software without specific prior written permission. 21 | 22 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 23 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 24 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 25 | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 26 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 27 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 28 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY 29 | OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 30 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED 31 | OF THE POSSIBILITY OF SUCH DAMAGE. 32 | 33 | -------------------------------------------------------------------------------- /wanikani_api/url_builder.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | 4 | class UrlBuilder: 5 | def __init__(self, api_root): 6 | self.api_root = api_root 7 | 8 | def build_wk_url(self, endpoint, parameters=None, resource_id=None): 9 | if resource_id is not None: 10 | return "{0}{1}/{2}".format(self.api_root, endpoint, resource_id) 11 | else: 12 | parameter_string = self._build_query_parameters(parameters) 13 | return "{0}{1}{2}".format(self.api_root, endpoint, parameter_string) 14 | 15 | def _parse_parameter(self, parameter): 16 | key = parameter[0] 17 | value = parameter[1] 18 | if self._parameter_should_be_ignored(key, value): 19 | return None 20 | if isinstance(value, list): 21 | return "{}={}".format(key, ",".join(str(elem) for elem in value)) 22 | elif isinstance(value, bool): 23 | return "{}={}".format(key, str(value).lower()) 24 | elif isinstance(value, datetime.datetime): 25 | return "{}={}".format(key, value.isoformat()) 26 | else: 27 | return "{}={}".format(key, str(value)) 28 | 29 | def _parameter_should_be_ignored(self, key, value): 30 | return value is None or key in ["self", "resource_id", "fetch_all"] 31 | 32 | def _build_query_parameters(self, parameters): 33 | if parameters: 34 | query_parameters = list( 35 | map(self._parse_parameter, sorted(parameters.items())) 36 | ) 37 | query_parameters = [qp for qp in query_parameters if qp is not None] 38 | if query_parameters: 39 | return "?{}".format("&".join(query_parameters)) 40 | return "" 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # pycharm 102 | .idea/ 103 | .idea 104 | 105 | # mypy 106 | .mypy_cache/ 107 | -------------------------------------------------------------------------------- /tests/test_mixins.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests for `wanikani_api` package.""" 5 | import requests 6 | 7 | from wanikani_api.client import Client 8 | from wanikani_api.models import Subject, UserInformation 9 | from tests.utils.utils import ( 10 | mock_user_info, 11 | mock_subjects, 12 | mock_assignments, 13 | mock_review_statistics, 14 | mock_study_materials, 15 | mock_summary, 16 | mock_reviews, 17 | mock_level_progressions, 18 | mock_resets, 19 | mock_single_subject, 20 | ) 21 | 22 | 23 | def test_subjectable_mixin_works(requests_mock): 24 | mock_subjects(requests_mock) 25 | mock_assignments(requests_mock) 26 | client = Client("arbitrary_api_key", subject_cache_enabled=True) 27 | 28 | assignments = client.assignments() 29 | assignment = assignments[0] 30 | subj = assignment.subject 31 | assert subj.id == assignment.subject_id 32 | 33 | 34 | def test_expected_subjectable_resources_work(requests_mock): 35 | mock_assignments(requests_mock) 36 | mock_summary(requests_mock) 37 | mock_subjects(requests_mock) 38 | mock_reviews(requests_mock) 39 | mock_single_subject(requests_mock) 40 | mock_study_materials(requests_mock) 41 | mock_summary(requests_mock) 42 | 43 | client = Client("arbitrary_api_key") 44 | 45 | assignments = client.assignments() 46 | assert assignments[0].subject is not None 47 | 48 | study_materials = client.study_materials() 49 | assert study_materials[0].subject is not None 50 | 51 | study_materials = client.study_materials() 52 | assert study_materials[0].subject is not None 53 | 54 | summary = client.summary() 55 | assert summary.reviews[0].subjects is not None 56 | 57 | reviews = client.reviews() 58 | assert reviews[0].subject is not None 59 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests for `wanikani_api` package.""" 5 | import datetime 6 | 7 | import requests 8 | from tests.utils.utils import mock_subjects, mock_empty_subjects 9 | 10 | from wanikani_api.client import Client 11 | from wanikani_api.models import Iterator 12 | 13 | 14 | class Empty200: 15 | def __init__(self): 16 | self.status_code = 200 17 | 18 | 19 | class MockedRequest: 20 | def __init__(self, *args, **kwargs): 21 | self.status_code = 200 22 | 23 | 24 | def test_subject_parameters_are_properly_converted(requests_mock): 25 | mock_subjects(requests_mock) 26 | 27 | v2_api_key = "arbitrary_api_key" 28 | client = Client(v2_api_key) 29 | 30 | client.subjects(ids=[1, 2, 3], hidden=False, slugs=["abc", "123"]) 31 | 32 | assert requests_mock.call_count == 1 33 | assert ( 34 | requests_mock.request_history[0].url 35 | == "https://api.wanikani.com/v2/subjects?hidden=false&ids=1,2,3&slugs=abc,123" 36 | ) 37 | 38 | 39 | def test_client_correctly_renders_empty_collections(requests_mock): 40 | mock_empty_subjects(requests_mock) 41 | v2_api_key = "arbitrary_api_key" 42 | client = Client(v2_api_key) 43 | response = client.subjects(ids=[1, 2, 3], hidden=False, slugs=["abc", "123"]) 44 | assert len(response.current_page.data) == 0 45 | 46 | 47 | def test_parameters_convert_datetime_to_string_correctly(requests_mock): 48 | mock_subjects(requests_mock) 49 | v2_api_key = "arbitrary_api_key" 50 | client = Client(v2_api_key) 51 | now = datetime.datetime.now() 52 | 53 | client.subjects(updated_after=now) 54 | 55 | assert requests_mock.call_count == 1 56 | assert ( 57 | requests_mock.request_history[0].url 58 | == "https://api.wanikani.com/v2/subjects?updated_after=" + now.isoformat() 59 | ) 60 | 61 | 62 | def test_requests_mock(requests_mock): 63 | mock_subjects(requests_mock) 64 | 65 | client = Client("whatever") 66 | subjects = client.subjects() 67 | assert isinstance(subjects, Iterator) 68 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import os, webbrowser, sys 6 | 7 | try: 8 | from urllib import pathname2url 9 | except: 10 | from urllib.request import pathname2url 11 | 12 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 13 | endef 14 | export BROWSER_PYSCRIPT 15 | 16 | define PRINT_HELP_PYSCRIPT 17 | import re, sys 18 | 19 | for line in sys.stdin: 20 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 21 | if match: 22 | target, help = match.groups() 23 | print("%-20s %s" % (target, help)) 24 | endef 25 | export PRINT_HELP_PYSCRIPT 26 | 27 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 28 | 29 | help: 30 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 31 | 32 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 33 | 34 | clean-build: ## remove build artifacts 35 | rm -fr build/ 36 | rm -fr dist/ 37 | rm -fr .eggs/ 38 | find . -name '*.egg-info' -exec rm -fr {} + 39 | find . -name '*.egg' -exec rm -f {} + 40 | 41 | clean-pyc: ## remove Python file artifacts 42 | find . -name '*.pyc' -exec rm -f {} + 43 | find . -name '*.pyo' -exec rm -f {} + 44 | find . -name '*~' -exec rm -f {} + 45 | find . -name '__pycache__' -exec rm -fr {} + 46 | 47 | clean-test: ## remove test and coverage artifacts 48 | rm -fr .tox/ 49 | rm -f .coverage 50 | rm -fr htmlcov/ 51 | rm -fr .pytest_cache 52 | 53 | lint: ## check style with flake8 54 | flake8 wanikani_api tests 55 | 56 | test: ## run tests quickly with the default Python 57 | py.test 58 | 59 | test-all: ## run tests on every Python version with tox 60 | tox 61 | 62 | coverage: ## check code coverage quickly with the default Python 63 | coverage run --source wanikani_api -m pytest 64 | coverage report -m 65 | coverage html 66 | $(BROWSER) htmlcov/index.html 67 | 68 | docs: ## generate Sphinx HTML documentation, including API docs 69 | rm -f docs/wanikani_api.rst 70 | rm -f docs/modules.rst 71 | sphinx-apidoc -o docs/ wanikani_api 72 | $(MAKE) -C docs clean 73 | $(MAKE) -C docs html 74 | $(BROWSER) docs/_build/html/index.html 75 | 76 | servedocs: docs ## compile the docs watching for changes 77 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 78 | 79 | release: dist ## package and upload a release 80 | twine upload dist/* 81 | 82 | dist: clean ## builds source and wheel package 83 | python setup.py sdist 84 | python setup.py bdist_wheel 85 | ls -l dist 86 | 87 | install: clean ## install the package to the active Python's site-packages 88 | python setup.py install 89 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====================== 2 | |wk_logo| wanikani_api 3 | ====================== 4 | 5 | .. |wk_logo| image:: https://discourse-cdn-sjc1.com/business5/uploads/wanikani_community/original/3X/7/a/7a2bd7e8dcf8d7766b51a77960d86949215c830c.png?v=5 6 | :target: https://wanikani.com 7 | :align: middle 8 | 9 | 10 | .. image:: https://img.shields.io/pypi/v/wanikani_api.svg 11 | :target: https://pypi.python.org/pypi/wanikani_api 12 | 13 | .. image:: https://img.shields.io/travis/Kaniwani/wanikani_api.svg 14 | :target: https://travis-ci.org/Kaniwani/wanikani_api 15 | 16 | .. image:: https://readthedocs.org/projects/wanikani-api/badge/?version=latest 17 | :target: https://wanikani-api.readthedocs.io/en/latest/?badge=latest 18 | :alt: Documentation Status 19 | 20 | 21 | .. image:: https://pyup.io/repos/github/Kaniwani/wanikani_api/shield.svg 22 | :target: https://pyup.io/repos/github/Kaniwani/wanikani_api/ 23 | :alt: Updates 24 | 25 | 26 | An API wrapper for Wanikani (V2) 27 | 28 | 29 | * Free software: BSD license 30 | * Documentation: https://wanikani-api.readthedocs.io. 31 | 32 | 33 | Features 34 | -------- 35 | 36 | * Easy access to Wanikani resources associated to your account. 37 | * Automatic handling of pagination. 38 | * Automatic fetching of related Subjects 39 | 40 | 41 | Quickstart 42 | ---------- 43 | 44 | .. code-block:: python 45 | 46 | >>> from wanikani_api.client import Client 47 | >>> v2_api_key = "drop_your_v2_api_key_in_here" # You can get it here: https://www.wanikani.com/settings/account 48 | >>> client = Client(v2_api_key) 49 | >>> user_information = client.user_information() 50 | >>> print(user_information) 51 | UserInformation{ username:Tadgh11, level:8, max_level_granted_by_subscription:60, profile_url:https://www.wanikani.com/users/Tadgh11 started_at:2013-07-09 12:02:54.952786+00:00, subscribed:True, current_vacation_started_at:None } 52 | >>> all_vocabulary = client.subjects(types="vocabulary") 53 | >>> for vocab in all_vocabulary: 54 | >>> print(vocab.meanings[0].meaning) #Vocabulary may have multiple meanings, we just grab the first in the list. 55 | One 56 | One Thing 57 | Seven 58 | Seven Things 59 | Nine 60 | Nine Things 61 | Two 62 | ... 63 | 64 | 65 | TODO 66 | ---- 67 | * Make use of ETags for caching 68 | * simplify API 69 | * Improve automatic prefetching of subjects when relevant. 70 | 71 | Credits 72 | ------- 73 | 74 | This package was created with Cookiecutter_ and the `audreyr/cookiecutter-pypackage`_ project template. 75 | 76 | .. _Cookiecutter: https://github.com/audreyr/cookiecutter 77 | .. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage 78 | -------------------------------------------------------------------------------- /tests/utils/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from wanikani_api import constants 4 | from .response_mocks import * 5 | 6 | 7 | def mock_subjects(requests_mock): 8 | requests_mock.get( 9 | re.compile(constants.SUBJECT_ENDPOINT), 10 | json=SUBJECTS_PAGE, 11 | headers={"Etag": "abc123"}, 12 | ) 13 | 14 | 15 | def mock_single_subject(requests_mock): 16 | requests_mock.get( 17 | re.compile(constants.SINGLE_SUBJECT_ENPOINT), 18 | json=SINGLE_SUBJECT, 19 | headers={"Etag": "abc123"}, 20 | ) 21 | 22 | 23 | def mock_empty_subjects(requests_mock): 24 | requests_mock.get( 25 | re.compile(constants.SUBJECT_ENDPOINT), 26 | json=EMPTY_SUBJECTS_PAGE, 27 | headers={"Etag": "abc123"}, 28 | ) 29 | 30 | 31 | # When making multiple calls to the subject endpoint, only answer with real data once, then just return a 304. 32 | def mock_subjects_with_cache(requests_mock): 33 | requests_mock.register_uri( 34 | "GET", 35 | re.compile(constants.SUBJECT_ENDPOINT), 36 | [ 37 | {"json": SUBJECTS_PAGE, "status_code": 200, "headers": {"Etag": "abc123"}}, 38 | {"json": None, "status_code": 304}, 39 | ], 40 | ) 41 | 42 | 43 | def mock_user_info(requests_mock): 44 | requests_mock.get( 45 | re.compile(constants.USER_ENDPOINT), 46 | json=USER_INFORMATION, 47 | headers={"Etag": "abc123"}, 48 | ) 49 | 50 | 51 | def mock_assignments(requests_mock): 52 | requests_mock.get( 53 | re.compile(constants.ASSIGNMENT_ENDPOINT), 54 | json=ASSIGNMENTS_PAGE, 55 | headers={"Etag": "abc123"}, 56 | ) 57 | 58 | 59 | def mock_review_statistics(requests_mock): 60 | requests_mock.get( 61 | re.compile(constants.REVIEW_STATS_ENDPOINT), 62 | json=REVIEW_STATISTICS_PAGE, 63 | headers={"Etag": "abc123"}, 64 | ) 65 | 66 | 67 | def mock_level_progressions(requests_mock): 68 | requests_mock.get( 69 | re.compile(constants.LEVEL_PROGRESSIONS_ENDPOINT), 70 | json=LEVEL_PROGRESSIONS_PAGE, 71 | headers={"Etag": "abc123"}, 72 | ) 73 | 74 | 75 | def mock_summary(requests_mock): 76 | requests_mock.get( 77 | re.compile(constants.SUMMARY_ENDPOINT), json=SUMMARY, headers={"Etag": "abc123"} 78 | ) 79 | 80 | 81 | def mock_resets(requests_mock): 82 | requests_mock.get( 83 | re.compile(constants.RESETS_ENDPOINT), 84 | json=RESETS_PAGE, 85 | headers={"Etag": "abc123"}, 86 | ) 87 | 88 | 89 | def mock_reviews(requests_mock): 90 | requests_mock.get( 91 | re.compile(constants.REVIEWS_ENDPOINT), 92 | json=REVIEWS_PAGE, 93 | headers={"Etag": "abc123"}, 94 | ) 95 | 96 | 97 | def mock_study_materials(requests_mock): 98 | requests_mock.get( 99 | re.compile(constants.STUDY_MATERIALS_ENDPOINT), 100 | json=STUDY_MATERIALS_PAGE, 101 | headers={"Etag": "abc123"}, 102 | ) 103 | -------------------------------------------------------------------------------- /tests/test_url_builder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests for `wanikani_api` package.""" 5 | import datetime 6 | 7 | import pytest 8 | import requests 9 | 10 | from tests.utils.utils import mock_subjects, mock_assignments 11 | from wanikani_api.client import Client 12 | from wanikani_api.constants import ROOT_WK_API_URL 13 | 14 | 15 | @pytest.fixture 16 | def url_builder(): 17 | from wanikani_api.url_builder import UrlBuilder 18 | 19 | return UrlBuilder(ROOT_WK_API_URL) 20 | 21 | 22 | def test_url_builder_with_no_parameters(url_builder): 23 | expected = "https://api.wanikani.com/v2/user" 24 | actual = url_builder.build_wk_url("user") 25 | assert actual == expected 26 | 27 | 28 | def test_url_builder_with_single_string_parameter(url_builder): 29 | expected = "https://api.wanikani.com/v2/subjects?types=vocabulary" 30 | actual = url_builder.build_wk_url("subjects", {"types": "vocabulary"}) 31 | assert actual == expected 32 | 33 | 34 | def test_url_builder_with_single_array_parameter(url_builder): 35 | expected = "https://api.wanikani.com/v2/subjects?types=vocabulary,kanji,radicals" 36 | actual = url_builder.build_wk_url( 37 | "subjects", {"types": ["vocabulary", "kanji", "radicals"]} 38 | ) 39 | assert actual == expected 40 | 41 | 42 | def test_url_builder_with_multiple_string_parameter(url_builder): 43 | expected = "https://api.wanikani.com/v2/subjects?slugs=女&types=vocabulary" 44 | actual = url_builder.build_wk_url("subjects", {"types": "vocabulary", "slugs": "女"}) 45 | assert actual == expected 46 | 47 | 48 | def test_url_builder_with_single_integer_parameter(url_builder): 49 | expected = "https://api.wanikani.com/v2/subjects?ids=1" 50 | actual = url_builder.build_wk_url("subjects", {"ids": 1}) 51 | assert actual == expected 52 | 53 | 54 | def test_subject_parameters_are_properly_converted(requests_mock): 55 | mock_subjects(requests_mock) 56 | v2_api_key = "arbitrary_api_key" 57 | client = Client(v2_api_key) 58 | 59 | client.subjects(ids=[1, 2, 3], hidden=False, slugs=["abc", "123"]) 60 | 61 | assert requests_mock.call_count == 1 62 | assert ( 63 | requests_mock.request_history[0].url 64 | == "https://api.wanikani.com/v2/subjects?hidden=false&ids=1,2,3&slugs=abc,123" 65 | ) 66 | 67 | 68 | def test_parameters_convert_datetime_to_string_correctly(requests_mock): 69 | mock_subjects(requests_mock) 70 | v2_api_key = "arbitrary_api_key" 71 | client = Client(v2_api_key) 72 | now = datetime.datetime.now() 73 | 74 | client.subjects(updated_after=now) 75 | 76 | assert requests_mock.call_count == 1 77 | assert ( 78 | requests_mock.request_history[0].url 79 | == "https://api.wanikani.com/v2/subjects?updated_after=" + now.isoformat() 80 | ) 81 | 82 | 83 | def test_assignment_parameters_are_properly_converted(requests_mock): 84 | mock_assignments(requests_mock) 85 | v2_api_key = "arbitrary_api_key" 86 | client = Client(v2_api_key) 87 | 88 | client.assignments(ids=[1, 2, 3], hidden=False, srs_stages=[0, 1, 2]) 89 | 90 | assert requests_mock.call_count == 1 91 | assert ( 92 | requests_mock.request_history[0].url 93 | == "https://api.wanikani.com/v2/assignments?hidden=false&ids=1,2,3&srs_stages=0,1,2" 94 | ) 95 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/kaniwani/wanikani_api/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | wanikani_api could always use more documentation, whether as part of the 42 | official wanikani_api docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/kaniwani/wanikani_api/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `wanikani_api` for local development. 61 | 62 | 1. Fork the `wanikani_api` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/wanikani_api.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv wanikani_api 70 | $ cd wanikani_api/ 71 | $ python setup.py develop 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8 and the 80 | tests, including testing other Python versions with tox:: 81 | 82 | $ flake8 wanikani_api tests 83 | $ python setup.py test or py.test 84 | $ tox 85 | 86 | To get flake8 and tox, just pip install them into your virtualenv. 87 | 88 | 6. Commit your changes and push your branch to GitHub:: 89 | 90 | $ git add . 91 | $ git commit -m "Your detailed description of your changes." 92 | $ git push origin name-of-your-bugfix-or-feature 93 | 94 | 7. Submit a pull request through the GitHub website. 95 | 96 | Pull Request Guidelines 97 | ----------------------- 98 | 99 | Before you submit a pull request, check that it meets these guidelines: 100 | 101 | 1. The pull request should include tests. 102 | 2. If the pull request adds functionality, the docs should be updated. Put 103 | your new functionality into a function with a docstring, and add the 104 | feature to the list in README.rst. 105 | 3. The pull request should work for Python 3.4, 3.5 and 3.6, and for PyPy. Check 106 | https://travis-ci.org/Kaniwani/wanikani_api/pull_requests 107 | and make sure that the tests pass for all supported Python versions. 108 | 109 | Tips 110 | ---- 111 | 112 | To run a subset of tests:: 113 | 114 | $ py.test tests.test_wanikani_api 115 | 116 | 117 | Deploying 118 | --------- 119 | 120 | A reminder for the maintainers on how to deploy. 121 | Make sure all your changes are committed (including an entry in HISTORY.rst). 122 | Then run:: 123 | 124 | $ bumpversion patch # possible: major / minor / patch 125 | $ git push 126 | $ git push --tags 127 | 128 | Travis will then deploy to PyPI if tests pass. 129 | -------------------------------------------------------------------------------- /tests/test_wanikani_api.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests for `wanikani_api` package.""" 5 | 6 | from wanikani_api.client import Client 7 | from wanikani_api.models import Subject, UserInformation 8 | from tests.utils.utils import ( 9 | mock_user_info, 10 | mock_subjects, 11 | mock_assignments, 12 | mock_review_statistics, 13 | mock_study_materials, 14 | mock_summary, 15 | mock_reviews, 16 | mock_level_progressions, 17 | mock_resets, 18 | mock_subjects_with_cache, 19 | mock_single_subject, 20 | ) 21 | 22 | 23 | def test_client_can_get_user_information(requests_mock): 24 | mock_user_info(requests_mock) 25 | 26 | client = Client("v2_api_key") 27 | 28 | user = client.user_information() 29 | assert isinstance(user, UserInformation) 30 | 31 | 32 | def test_client_can_get_subjects(requests_mock): 33 | mock_subjects(requests_mock) 34 | 35 | client = Client("v2_api_key") 36 | 37 | subjects = client.subjects() 38 | assert len(subjects.current_page.data) > 0 39 | assert subjects.current_page.data[0].resource in ["vocabulary", "kanji", "radical"] 40 | 41 | 42 | def test_client_can_get_assignments(requests_mock): 43 | mock_assignments(requests_mock) 44 | 45 | client = Client("v2_api_key") 46 | 47 | assignments = client.assignments() 48 | 49 | assert len(assignments.current_page.data) > 0 50 | 51 | 52 | def test_client_can_get_review_statistics(requests_mock): 53 | mock_review_statistics(requests_mock) 54 | client = Client("v2_api_key") 55 | 56 | review_statistics = client.review_statistics() 57 | assert len(review_statistics.current_page.data) > 0 58 | 59 | 60 | def test_client_can_get_study_materials(requests_mock): 61 | mock_study_materials(requests_mock) 62 | client = Client("v2_api_key") 63 | 64 | study_materials = client.study_materials() 65 | assert len(study_materials.current_page.data) > 0 66 | 67 | 68 | def test_client_can_get_summary(requests_mock): 69 | mock_summary(requests_mock) 70 | client = Client("v2_api_key") 71 | 72 | summary = client.summary() 73 | assert summary.lessons is not None 74 | assert summary.reviews is not None 75 | 76 | 77 | def test_client_can_get_reviews(requests_mock): 78 | mock_reviews(requests_mock) 79 | client = Client("v2_api_key") 80 | 81 | reviews = client.reviews() 82 | assert len(reviews.current_page.data) > 0 83 | 84 | 85 | def test_client_can_get_level_progression(requests_mock): 86 | mock_level_progressions(requests_mock) 87 | client = Client("v2_api_key") 88 | 89 | progressions = client.level_progressions() 90 | assert len(progressions.current_page.data) > 0 91 | 92 | 93 | def test_client_can_get_resets(requests_mock): 94 | mock_resets(requests_mock) 95 | client = Client("v2_api_key") 96 | 97 | resets = client.resets() 98 | assert len(resets.current_page.data) == 1 99 | 100 | 101 | def test_singular_subject_retrieval(requests_mock): 102 | mock_single_subject(requests_mock) 103 | v2_api_key = "arbitrary_api_key" 104 | client = Client(v2_api_key) 105 | 106 | subject = client.subject(1) 107 | assert isinstance(subject, Subject) 108 | 109 | 110 | def test_client_uses_cache(requests_mock): 111 | mock_subjects(requests_mock) 112 | mock_single_subject(requests_mock) 113 | mock_assignments(requests_mock) 114 | v2_api_key = "arbitrary_api_key" 115 | client = Client(v2_api_key, subject_cache_enabled=True) 116 | assignments = client.assignments() 117 | for ass in assignments: 118 | print(ass.subject.level) # in theory here, if we have _not_ cached 119 | 120 | assert requests_mock.call_count == 2 121 | history = requests_mock.request_history 122 | assert "subjects" in history[0].url 123 | assert "assignments" in history[1].url 124 | 125 | 126 | def test_etag_cache_decorator_works(mocker, requests_mock): 127 | mock_subjects_with_cache(requests_mock) 128 | v2_api_key = "arbitrary_api_key" 129 | client = Client(v2_api_key) 130 | 131 | mocker.spy(client, "_fetch_result_from_cache") 132 | subjects = client.subjects() 133 | cached_subjects = client.subjects() 134 | assert client._fetch_result_from_cache.call_count == 1 135 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: python 2 | 3 | ===== 4 | Usage 5 | ===== 6 | Getting a client 7 | ________________ 8 | .. code-block:: python 9 | 10 | >>> import wanikani_api.client as client 11 | >>> wk_api = client.Client("enter your V2 API key here") 12 | 13 | User Information 14 | ________________ 15 | .. code-block:: python 16 | 17 | >>> import wanikani_api.client as client 18 | >>> wk_api = client.Client("enter your V2 API key here") 19 | >>> user_info = wk_api.user_information() 20 | >>> user.username 21 | "Tadgh" 22 | 23 | Subjects 24 | ________ 25 | 26 | This is how to retrieve all Subjects in Wanikani. Subjects are either :class:`.models.Kanji`, :class:`.models.Radical`, or :class:`.models.Vocabulary`. 27 | 28 | .. code-block:: python 29 | 30 | >>> vocabulary = wk_api.subjects(types="vocabulary") 31 | >>> for vocab in vocabulary: 32 | >>> print(vocab.readings[0].reading) 33 | "いち" 34 | "ひとつ" 35 | "なな" 36 | "ななつ" 37 | "きゅう" 38 | "ここのつ" 39 | ... 40 | >>> print(len(vocabulary)) 41 | 1000 42 | 43 | 44 | Note that by default the client will only retrieve the first Page of results. This can be changed by passing ``fetch_all=True`` to any client function 45 | which returns multiple results. Like so: 46 | 47 | >>> vocabulary = wk_api.subjects(types="vocabulary", fetch_all=True) 48 | >>> print(len(vocabulary)) 49 | 6301 50 | 51 | Alternatively, if you decide afterwards you'd like to fill in the missing data, you can do this: 52 | 53 | >>> vocabulary = wk_api.subjects(types="vocabulary") 54 | >>> print(len(vocabulary)) 55 | 1000 56 | >>> vocabulary.fetch_all_pages() 57 | >>> print(len(vocabulary)) 58 | 6301 59 | 60 | You are also free to fetch one page at a time. Note also that you can access indiviual :class:`.models.Page` objects if you like. 61 | 62 | >>> vocabulary = wk_api.subjects(types="vocabulary") 63 | >>> print(len(vocabulary)) 64 | 1000 65 | >>> vocabulary.fetch_next_page() 66 | >>> print(len(vocabulary)) 67 | 2000 68 | >>> print(len(vocabulary.pages)) 69 | 2 70 | # Iterate only over elements in the second page: 71 | >>> for vocab in vocabulary.pages[1]: 72 | >>> print(vocab.parts_of_speech) 73 | ['noun', 'suru_verb'] 74 | ['noun'] 75 | ['intransitive_verb', 'godan_verb'] 76 | 77 | This works for any client function that is *plural*, e.g. assignments(), subjects(), reviews(), etc. 78 | 79 | By default, the Wanikani API returns only subject IDs when referring to a subject. Therefore, for any resource which contains a field *subject_id* or *subject_ids* can make use of convenient properties *subject* and *subjects*, respectively. 80 | This allows you to quickly grab related subjects without making a separate explicit call to the subjects endpoint. See below. 81 | 82 | Assignments 83 | ___________ 84 | .. code-block:: python 85 | 86 | >>> assignments = wk_api.assignments(subject_types="vocabulary") 87 | >>> for assignment in assignments: 88 | >>> print(assignment.srs_stage) 89 | >>> print(assignment.subject.meaning) # The client will automatically go and fetch this subject for you. 90 | 9 91 | "One" 92 | 9 93 | "One Thing" 94 | 95 | 96 | Note that the above will make a new API call every time you call ``subject`` on a new assignment. 97 | 98 | Review Statistics 99 | _________________ 100 | 101 | Here's how to get your review statistics for your level 30 vocabulary and kanji (but not radicals), that you have gotten correct at most 50% 102 | 103 | .. code-block:: python 104 | 105 | >>> subjects = wk_api.subjects(types=["vocabulary", "kanji"], level=30) 106 | >>> stats = wk_api.review_statistics(subject_ids=[subject.id for subject in subjects], percentages_less_than=50) 107 | >>> for stat in stats: 108 | >>> print(stat.percentage_correct) 109 | 44 110 | 42 111 | 49 112 | 31 113 | 114 | Study Materials 115 | _______________ 116 | 117 | Here's how to get all study materials for any vocabulary that have the slug 毛糸. The *slug* is a simple identifier on the wanikani site 118 | (like this: https://www.wanikani.com/vocabulary/毛糸) 119 | 120 | .. code-block:: python 121 | 122 | >>> subjects = wk_api.subjects(slugs="毛糸", types="vocabulary") 123 | >>> study_mats = wk_api.study_materials(subject_ids=[subject.id for subject in subjects]) 124 | >>> for study_material in study_mats: 125 | >>> print (", ".join(study_material.meaning_synonyms) 126 | "wool,yarn" 127 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # wanikani_api documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Jun 9 13:47:02 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another 17 | # directory, add these directories to sys.path here. If the directory is 18 | # relative to the documentation root, use os.path.abspath to make it 19 | # absolute, like shown here. 20 | # 21 | import os 22 | import sys 23 | 24 | sys.path.insert(0, os.path.abspath("..")) 25 | 26 | import wanikani_api 27 | 28 | # -- General configuration --------------------------------------------- 29 | 30 | # If your documentation needs a minimal Sphinx version, state it here. 31 | # 32 | # needs_sphinx = '1.0' 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 36 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode"] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ["_templates"] 40 | 41 | # The suffix(es) of source filenames. 42 | # You can specify multiple suffix as a list of string: 43 | # 44 | # source_suffix = ['.rst', '.md'] 45 | source_suffix = ".rst" 46 | 47 | # The master toctree document. 48 | master_doc = "index" 49 | 50 | # General information about the project. 51 | project = u"wanikani_api" 52 | copyright = u"2018, Gary Grant Graham" 53 | author = u"Gary Grant Graham" 54 | 55 | # The version info for the project you're documenting, acts as replacement 56 | # for |version| and |release|, also used in various other places throughout 57 | # the built documents. 58 | # 59 | # The short X.Y version. 60 | version = wanikani_api.__version__ 61 | # The full version, including alpha/beta/rc tags. 62 | release = wanikani_api.__version__ 63 | 64 | # The language for content autogenerated by Sphinx. Refer to documentation 65 | # for a list of supported languages. 66 | # 67 | # This is also used if you do content translation via gettext catalogs. 68 | # Usually you set "language" from the command line for these cases. 69 | language = None 70 | 71 | # List of patterns, relative to source directory, that match files and 72 | # directories to ignore when looking for source files. 73 | # This patterns also effect to html_static_path and html_extra_path 74 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 75 | 76 | # The name of the Pygments (syntax highlighting) style to use. 77 | pygments_style = "sphinx" 78 | 79 | # If true, `todo` and `todoList` produce output, else they produce nothing. 80 | todo_include_todos = False 81 | 82 | 83 | # -- Options for HTML output ------------------------------------------- 84 | 85 | # The theme to use for HTML and HTML Help pages. See the documentation for 86 | # a list of builtin themes. 87 | # 88 | html_theme = "sphinx_rtd_theme" 89 | 90 | # Theme options are theme-specific and customize the look and feel of a 91 | # theme further. For a list of options available for each theme, see the 92 | # documentation. 93 | # 94 | # html_theme_options = {} 95 | 96 | # Add any paths that contain custom static files (such as style sheets) here, 97 | # relative to this directory. They are copied after the builtin static files, 98 | # so a file named "default.css" will overwrite the builtin "default.css". 99 | html_static_path = ["_static"] 100 | 101 | 102 | # -- Options for HTMLHelp output --------------------------------------- 103 | 104 | # Output file base name for HTML help builder. 105 | htmlhelp_basename = "wanikani_apidoc" 106 | 107 | 108 | # -- Options for LaTeX output ------------------------------------------ 109 | 110 | latex_elements = { 111 | # The paper size ('letterpaper' or 'a4paper'). 112 | # 113 | # 'papersize': 'letterpaper', 114 | # The font size ('10pt', '11pt' or '12pt'). 115 | # 116 | # 'pointsize': '10pt', 117 | # Additional stuff for the LaTeX preamble. 118 | # 119 | # 'preamble': '', 120 | # Latex figure (float) alignment 121 | # 122 | # 'figure_align': 'htbp', 123 | } 124 | 125 | # Grouping the document tree into LaTeX files. List of tuples 126 | # (source start file, target name, title, author, documentclass 127 | # [howto, manual, or own class]). 128 | latex_documents = [ 129 | ( 130 | master_doc, 131 | "wanikani_api.tex", 132 | u"wanikani_api Documentation", 133 | u"Gary Grant Graham", 134 | "manual", 135 | ) 136 | ] 137 | 138 | 139 | # -- Options for manual page output ------------------------------------ 140 | 141 | # One entry per manual page. List of tuples 142 | # (source start file, name, description, authors, manual section). 143 | man_pages = [(master_doc, "wanikani_api", u"wanikani_api Documentation", [author], 1)] 144 | 145 | 146 | # -- Options for Texinfo output ---------------------------------------- 147 | 148 | # Grouping the document tree into Texinfo files. List of tuples 149 | # (source start file, target name, title, author, 150 | # dir menu entry, description, category) 151 | texinfo_documents = [ 152 | ( 153 | master_doc, 154 | "wanikani_api", 155 | u"wanikani_api Documentation", 156 | author, 157 | "wanikani_api", 158 | "One line description of project.", 159 | "Miscellaneous", 160 | ) 161 | ] 162 | -------------------------------------------------------------------------------- /tests/live_api_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests for `wanikani_api` package.""" 5 | 6 | from wanikani_api.client import Client 7 | from wanikani_api.models import Subject, UserInformation, Kanji, Radical, Vocabulary 8 | from tests.utils.utils import ( 9 | mock_user_info, 10 | mock_subjects, 11 | mock_assignments, 12 | mock_review_statistics, 13 | mock_study_materials, 14 | mock_summary, 15 | mock_reviews, 16 | mock_level_progressions, 17 | mock_resets, 18 | mock_subjects_with_cache, 19 | mock_single_subject, 20 | ) 21 | 22 | api_key = "2510f001-fe9e-414c-ba19-ccf79af40060" 23 | 24 | 25 | def test_real_connection_to_wanikani_user_information(): 26 | client = Client(api_key) 27 | 28 | user = client.user_information() 29 | assert isinstance(user, UserInformation) 30 | 31 | def test_real_connection_to_subjects(): 32 | client = Client(api_key) 33 | subjects = client.subjects() 34 | assert len(subjects.current_page.data) > 0 35 | assert subjects.current_page.data[0].resource in ["vocabulary", "kanji", "radical"] 36 | 37 | def test_vocabulary_connection_gets_all_known_data(): 38 | client = Client(api_key) 39 | subject = client.subject(subject_id=2467) 40 | assert isinstance(subject, Vocabulary) 41 | assert subject.level == 1 42 | assert subject.slug == "一" 43 | assert subject.hidden_at == None 44 | assert subject.document_url == "https://www.wanikani.com/vocabulary/%E4%B8%80" 45 | assert subject.url == "https://api.wanikani.com/v2/subjects/2467" 46 | assert len(subject.meanings) == 1 47 | assert len(subject.auxiliary_meanings) == 1 48 | assert len(subject.readings) == 1 49 | assert len(subject.parts_of_speech) == 1 50 | assert len(subject.component_subject_ids) == 1 51 | assert subject.meaning_mnemonic == "As is the case with most vocab words that consist of a single kanji, this vocab word has the same meaning as the kanji it parallels, which is one." 52 | assert subject.reading_mnemonic == "When a vocab word is all alone and has no okurigana (hiragana attached to kanji) connected to it, it usually uses the kun'yomi reading. Numbers are an exception, however. When a number is all alone, with no kanji or okurigana, it is going to be the on'yomi reading, which you learned with the kanji. Just remember this exception for alone numbers and you'll be able to read future number-related vocab to come." 53 | assert len(subject.context_sentences) == 3 54 | first_sentence = subject.context_sentences[0] 55 | assert first_sentence.english == "Let’s meet up once." 56 | assert first_sentence.japanese == "一ど、あいましょう。" 57 | assert len(subject.pronunciation_audios) == 8 58 | assert subject.lesson_position == 44 59 | assert subject.spaced_repitition_system_id == 2 60 | 61 | def test_kanji_connection_gets_all_known_data(): 62 | client = Client(api_key) 63 | subject = client.subject(subject_id=440) 64 | assert isinstance(subject, Kanji) 65 | assert subject.level == 1 66 | assert subject.slug == "一" 67 | assert subject.hidden_at == None 68 | assert subject.document_url == "https://www.wanikani.com/kanji/%E4%B8%80" 69 | assert subject.url == "https://api.wanikani.com/v2/subjects/440" 70 | assert len(subject.meanings) == 1 71 | assert len(subject.auxiliary_meanings) == 1 72 | assert len(subject.readings) == 4 73 | assert len(subject.component_subject_ids) == 1 74 | assert len(subject.amalgamation_subject_ids) == 72 75 | assert "Lying on the " in subject.meaning_mnemonic 76 | assert "To remember the meaning of" in subject.meaning_hint 77 | assert "As you're sitting" in subject.reading_mnemonic 78 | assert "Make sure you feel the" in subject.reading_hint 79 | assert subject.lesson_position == 26 80 | assert subject.spaced_repitition_system_id == 2 81 | 82 | 83 | def test_radical_connection_gets_all_known_data(): 84 | client = Client(api_key) 85 | subject = client.subject(subject_id=1) 86 | assert isinstance(subject, Radical) 87 | assert subject.level == 1 88 | assert subject.slug == "ground" 89 | assert subject.hidden_at == None 90 | assert subject.document_url == "https://www.wanikani.com/radicals/ground" 91 | assert subject.url == "https://api.wanikani.com/v2/subjects/1" 92 | assert len(subject.meanings) == 1 93 | assert len(subject.auxiliary_meanings) == 0 94 | assert len(subject.amalgamation_subject_ids) == 72 95 | assert "This radical consists of" in subject.meaning_mnemonic 96 | assert subject.lesson_position == 0 97 | assert subject.spaced_repitition_system_id == 2 98 | 99 | 100 | 101 | def test_real_connection_to_assignments(): 102 | client = Client(api_key) 103 | assignments = client.assignments() 104 | assert len(assignments.current_page.data) > 0 105 | 106 | def test_real_connection_to_review_statistics(): 107 | client = Client(api_key) 108 | review_statistics = client.review_statistics() 109 | assert len(review_statistics.current_page.data) > 0 110 | 111 | def test_real_connection_to_study_materials(): 112 | client = Client(api_key) 113 | study_materials = client.study_materials() 114 | assert len(study_materials.current_page.data) > 0 115 | 116 | def test_real_connection_to_summary(): 117 | client = Client(api_key) 118 | summary = client.summary() 119 | assert summary.lessons is not None 120 | assert summary.reviews is not None 121 | 122 | def test_real_connection_to_reviews(): 123 | client = Client(api_key) 124 | reviews = client.reviews() 125 | assert len(reviews.current_page.data) > 0 126 | 127 | def test_real_connection_to_level_progression(): 128 | client = Client(api_key) 129 | progressions = client.level_progressions() 130 | 131 | def test_real_connection_to_resets(): 132 | client = Client(api_key) 133 | resets = client.resets() 134 | assert len(resets.current_page.data) == 0 135 | 136 | def test_real_connection_to_singular_subject(): 137 | client = Client(api_key) 138 | subject = client.subject(1) 139 | assert isinstance(subject, Subject) 140 | 141 | def test_client_uses_cache(requests_mock): 142 | mock_subjects(requests_mock) 143 | mock_assignments(requests_mock) 144 | v2_api_key = "arbitrary_api_key" 145 | client = Client(v2_api_key, subject_cache_enabled=True) 146 | assignments = client.assignments() 147 | for ass in assignments: 148 | print(ass.subject.level) # in theory here, if we have _not_ cached 149 | 150 | assert requests_mock.call_count == 2 151 | history = requests_mock.request_history 152 | assert "subjects" in history[0].url 153 | assert "assignments" in history[1].url 154 | 155 | def test_etag_cache_decorator_works(mocker, requests_mock): 156 | mock_subjects_with_cache(requests_mock) 157 | v2_api_key = "arbitrary_api_key" 158 | client = Client(v2_api_key) 159 | 160 | mocker.spy(client, "_fetch_result_from_cache") 161 | subjects = client.subjects() 162 | cached_subjects = client.subjects() 163 | assert client._fetch_result_from_cache.call_count == 1 164 | -------------------------------------------------------------------------------- /wanikani_api/client.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from wanikani_api import constants, models 4 | from wanikani_api.exceptions import InvalidWanikaniApiKeyException 5 | from wanikani_api.models import Iterator 6 | from wanikani_api.subjectcache import SubjectCache 7 | from wanikani_api.url_builder import UrlBuilder 8 | from copy import deepcopy 9 | 10 | 11 | """ 12 | This is the primary point of entry for accessing the Wanikani API. 13 | """ 14 | 15 | 16 | class Client: 17 | """ 18 | This is the only object you can instantiate. It provides access to each 19 | relevant API endpoint on Wanikani. 20 | """ 21 | 22 | def __init__(self, v2_api_key, subject_cache_enabled=False): 23 | self.v2_api_key = v2_api_key 24 | self.headers = {"Authorization": "Bearer {}".format(v2_api_key)} 25 | self.url_builder = UrlBuilder(constants.ROOT_WK_API_URL) 26 | self.subject_cache = None 27 | self.etag_cache = {} 28 | self.authorized_request_maker = self.build_authorized_requester(self.headers) 29 | if subject_cache_enabled: 30 | self.use_local_subject_cache() 31 | 32 | def _fetch_result_from_cache(self, request_key): 33 | return self.etag_cache[request_key]["result"] 34 | 35 | def _fetch_etag_from_cache(self, request_key): 36 | return self.etag_cache[request_key]["etag"] 37 | 38 | def _store_in_cache(self, request_key, response): 39 | etag = response.headers["Etag"] 40 | self.etag_cache[request_key]["etag"] = etag 41 | self.etag_cache[request_key]["result"] = self._serialize_wanikani_response( 42 | response 43 | ) 44 | 45 | def build_authorized_requester(self, headers): 46 | def _make_wanikani_api_request(url): 47 | request_key = (url, headers["Authorization"]) 48 | request_headers = deepcopy(headers) 49 | try: 50 | etag = self.etag_cache[request_key]["etag"] 51 | request_headers["If-None-Match"] = etag 52 | except KeyError: 53 | self.etag_cache[request_key] = {} 54 | finally: 55 | response = requests.get(url, headers=request_headers) 56 | if response.status_code == 304: 57 | return self._fetch_result_from_cache(request_key) 58 | elif response.status_code == 200: 59 | self._store_in_cache(request_key, response) 60 | return self.etag_cache[request_key]["result"] 61 | else: 62 | raise Exception(f"Failed to contact Wanikani: {response.content}") 63 | 64 | return _make_wanikani_api_request 65 | 66 | def use_local_subject_cache(self): 67 | self.subject_cache = SubjectCache(self.subjects(fetch_all=True)) 68 | 69 | def user_information(self): 70 | """ 71 | Gets all relevant information about the user. 72 | 73 | :raises: :class:`.exceptions.InvalidWanikaniApiKeyException` 74 | :rtype: :class:`.models.UserInformation` 75 | """ 76 | response = requests.get( 77 | self.url_builder.build_wk_url(constants.USER_ENDPOINT), headers=self.headers 78 | ) 79 | return self._serialize_wanikani_response(response) 80 | 81 | def subject(self, subject_id): 82 | """ 83 | Get a single subject by its known id 84 | 85 | :param subject_id: the id of the subject 86 | :return: a single :class:`.models.Subject`. This might be either: 87 | * :class:`.models.Radical` 88 | * :class:`.models.Kanji` 89 | * :class:`.models.Vocabulary` 90 | """ 91 | if self.subject_cache: 92 | return self.subject_cache.get(subject_id) 93 | response = requests.get( 94 | self.url_builder.build_wk_url( 95 | constants.SUBJECT_ENDPOINT, resource_id=subject_id 96 | ), 97 | headers=self.headers, 98 | ) 99 | return self._serialize_wanikani_response(response) 100 | 101 | def subjects( 102 | self, 103 | ids=None, 104 | types=None, 105 | slugs=None, 106 | levels=None, 107 | hidden=None, 108 | updated_after=None, 109 | fetch_all=False, 110 | ): 111 | """Retrieves Subjects 112 | 113 | Wanikani refers to Radicals, Kanji, and Vocabulary as Subjects. This function allows you to fetch all of 114 | the subjects, regardless of the current level of the account that the API key is associated to. All parameters 115 | to this function are optional, and are for filtering the results. 116 | are ignored, and the subject with that ID in question is fetched. 117 | 118 | :param int[] ids: Filters based on a list of IDs. Does not cause other parameters to be ignored. 119 | :param str[] types: The specific :class:`.models.Subject` types you wish to retrieve. Possible values are: ``["kanji", "vocabulary", "radicals"]`` 120 | :param str[] slugs: The wanikani slug 121 | :param int[] levels: Include only :class:`.models.Subject` from the specified levels. 122 | :param bool hidden: Return :class:`.models.Subject` which are or are not hidden from the user-facing application 123 | :param bool fetch_all: if set to True, instead of fetching only first page of results, will fetch them all. 124 | :param updated_after: Return results which have been updated after the timestamp 125 | :type updated_after: :class:`datetime.datetime` 126 | :return: An iterator over multiple :class:`models.Page` , in which the ``data`` field contains a list anything that is a :class:`.models.Subject`, e.g.: 127 | 128 | * :class:`.models.Radical` 129 | * :class:`.models.Kanji` 130 | * :class:`.models.Vocabulary` 131 | """ 132 | 133 | url = self.url_builder.build_wk_url( 134 | constants.SUBJECT_ENDPOINT, parameters=locals() 135 | ) 136 | return self._wrap_collection_in_iterator( 137 | self.authorized_request_maker(url), fetch_all 138 | ) 139 | 140 | def assignment(self, assignment_id): 141 | """ 142 | Get a single :class:`.models.Assignment` by its known id 143 | 144 | :param assignment_id: the id of the assignment 145 | :return: a single :class:`.models.Assignment` 146 | """ 147 | response = requests.get( 148 | self.url_builder.build_wk_url( 149 | constants.ASSIGNMENT_ENDPOINT, resource_id=assignment_id 150 | ), 151 | headers=self.headers, 152 | ) 153 | return self._serialize_wanikani_response(response) 154 | 155 | def assignments( 156 | self, 157 | ids=None, 158 | created_at=None, 159 | subject_ids=None, 160 | subject_types=None, 161 | levels=None, 162 | available_before=None, 163 | available_after=None, 164 | srs_stages=None, 165 | unlocked=None, 166 | started=None, 167 | passed=None, 168 | burned=None, 169 | resurrected=None, 170 | hidden=None, 171 | updated_after=None, 172 | fetch_all=False, 173 | ): 174 | """ 175 | Assignments are the association between a user, and a subject. This means that every time something is added to 176 | your lessons, a new :class:`.models.Assignment` is created. 177 | 178 | :param bool fetch_all: if set to True, instead of fetching only first page of results, will fetch them all. 179 | :param int[] ids: Return only results with the given IDs 180 | :param created_at: Timestamp when resource was created 181 | :param int[] subject_ids: Return only :class:`.models.Assignment`s which are tied to the given subject_ids 182 | :param str[] subject_types: The specific :class:`.models.Subject` types you wish to retrieve. Possible values are: ``["kanji", "vocabulary", "radicals"]`` 183 | :param int[] levels: Include only :class:`.models.Assignment` where the subjects are from the specified levels. 184 | :param datetime available_before: Return assignment reviews available before timestamp 185 | :param datetime available_after: Return assignment reviews available after timestamp 186 | :param int srs_stages: Return assignments of specified srs stages. Note, 0 is lessons, 9 is the burned state 187 | :param bool unlocked: Return assignments which have unlocked (made available to lessons) 188 | :param bool started: Return assignments which move from lessons to reviews 189 | :param bool passed: Return assignments which have reach Guru (aka srs_stage 5) at some point (true) or which have never been Guru’d (false) 190 | :param bool burned: Return assignments which have been burned at some point (true) or never have been burned (false) 191 | :param bool resurrected: Return assignments which either have been resurrect (true) or not (false) 192 | :param bool hidden: Return assignments which are or are not hidden from the user-facing application 193 | :param datetime updated_after: Return results which have been updated after the timestamp 194 | :return: An iterator over a set of :class:`.models.Page` where the data contained is all :class:`.models.Assignment` 195 | """ 196 | response = requests.get( 197 | self.url_builder.build_wk_url( 198 | constants.ASSIGNMENT_ENDPOINT, parameters=locals() 199 | ), 200 | headers=self.headers, 201 | ) 202 | return self._wrap_collection_in_iterator( 203 | self._serialize_wanikani_response(response), fetch_all 204 | ) 205 | 206 | def review_statistic(self, review_statistic_id): 207 | """ 208 | Get a single :class:`.models.ReviewStatistic` by its known id 209 | 210 | :param review_statistic_id: the id of the review_statistic 211 | :return: a single :class:`.models.ReviewStatistic` 212 | """ 213 | response = requests.get( 214 | self.url_builder.build_wk_url( 215 | constants.REVIEW_STATS_ENDPOINT, resource_id=review_statistic_id 216 | ), 217 | headers=self.headers, 218 | ) 219 | return self._serialize_wanikani_response(response) 220 | 221 | def review_statistics( 222 | self, 223 | ids=None, 224 | subject_ids=None, 225 | subject_types=None, 226 | updated_after=None, 227 | percentages_greater_than=None, 228 | percentages_less_than=None, 229 | hidden=None, 230 | fetch_all=False, 231 | ): 232 | """ 233 | Retrieve all Review Statistics from Wanikani. A Review Statistic is related to a single subject which the user has studied. 234 | 235 | :param bool fetch_all: if set to True, instead of fetching only first page of results, will fetch them all. 236 | :param int[] ids: Return only results with the given IDs 237 | :param int[] subject_ids: Return only :class:`.models.Assignment`s which are tied to the given subject_ids 238 | :param str[] subject_types: The specific :class:`.models.Subject` types you wish to retrieve. Possible values are: ``["kanji", "vocabulary", "radicals"]`` 239 | :param datetime updated_after: Return results which have been updated after the timestamp 240 | :param int percentages_greater_than: Return results where the percentage_correct is greater than the value. [0-100] 241 | :param int percentages_less_than: Return results where the percentage_correct is less than the value. [0-100] 242 | :param bool hidden: Return only results where the related subject has been hidden. 243 | :return: An iterator which contains all Review Statistics 244 | """ 245 | response = requests.get( 246 | self.url_builder.build_wk_url( 247 | constants.REVIEW_STATS_ENDPOINT, parameters=locals() 248 | ), 249 | headers=self.headers, 250 | ) 251 | return self._wrap_collection_in_iterator( 252 | self._serialize_wanikani_response(response), fetch_all 253 | ) 254 | 255 | def study_material(self, study_material_id): 256 | """ 257 | Get a single :class:`.models.StudyMaterial` by its known id 258 | 259 | :param study_material_id: the id of the study material 260 | :return: a single :class:`.models.StudyMaterial` 261 | """ 262 | response = requests.get( 263 | self.url_builder.build_wk_url( 264 | constants.STUDY_MATERIALS_ENDPOINT, resource_id=study_material_id 265 | ), 266 | headers=self.headers, 267 | ) 268 | return self._serialize_wanikani_response(response) 269 | 270 | def study_materials( 271 | self, 272 | ids=None, 273 | subject_ids=None, 274 | subject_types=None, 275 | hidden=None, 276 | updated_after=None, 277 | fetch_all=False, 278 | ): 279 | """ 280 | Retrieve all Study Materials. These are primarily meaning notes, reading notes, and meaning synonyms. 281 | 282 | :param bool fetch_all: if set to True, instead of fetching only first page of results, will fetch them all. 283 | :param int[] ids: Return only results with the given IDs 284 | :param int[] subject_ids: Return only :class:`.models.Assignment`s which are tied to the given subject_ids 285 | :param str[] subject_types: The specific :class:`.models.Subject` types you wish to retrieve. Possible values are: ``["kanji", "vocabulary", "radicals"]`` 286 | :param bool hidden: Return only results where the related subject has been hidden. 287 | :param datetime updated_after: Return results which have been updated after the timestamp 288 | :return: An iterator over all Study Materials 289 | """ 290 | response = requests.get( 291 | self.url_builder.build_wk_url( 292 | constants.STUDY_MATERIALS_ENDPOINT, parameters=locals() 293 | ), 294 | headers=self.headers, 295 | ) 296 | return self._wrap_collection_in_iterator( 297 | self._serialize_wanikani_response(response), fetch_all 298 | ) 299 | 300 | def summary(self): 301 | """ 302 | 303 | :return: 304 | """ 305 | response = requests.get( 306 | self.url_builder.build_wk_url( 307 | constants.SUMMARY_ENDPOINT, parameters=locals() 308 | ), 309 | headers=self.headers, 310 | ) 311 | return self._serialize_wanikani_response(response) 312 | 313 | def review(self, review_id): 314 | """ 315 | Get a single :class:`.models.Review` by its known id 316 | 317 | :param review_id: the id of the review 318 | :return: a single :class:`.models.Review` 319 | """ 320 | response = requests.get( 321 | self.url_builder.build_wk_url( 322 | constants.REVIEWS_ENDPOINT, resource_id=review_id 323 | ), 324 | headers=self.headers, 325 | ) 326 | return self._serialize_wanikani_response(response) 327 | 328 | def reviews(self, ids=None, subject_ids=None, updated_after=None, fetch_all=False): 329 | """ 330 | Retrieve all reviews for a given user. A :class:`.models.Review` is a single instance of this user getting a 331 | single review correctly submitted. 332 | 333 | :param bool fetch_all: if set to True, instead of fetching only first page of results, will fetch them all. 334 | :param int[] ids: Return only results with the given IDs 335 | :param int[] subject_ids: Return only :class:`.models.Assignment`s which are tied to the given subject_ids 336 | :param datetime updated_after: Return results which have been updated after the timestamp 337 | :return: An iterator over all :class:`.models.Review` for a given user. 338 | """ 339 | response = requests.get( 340 | self.url_builder.build_wk_url( 341 | constants.REVIEWS_ENDPOINT, parameters=locals() 342 | ), 343 | headers=self.headers, 344 | ) 345 | return self._wrap_collection_in_iterator( 346 | self._serialize_wanikani_response(response), fetch_all 347 | ) 348 | 349 | def level_progression(self, level_progression_id): 350 | """ 351 | Get a single :class:`.models.LevelProgression` by its known id 352 | 353 | :param level_progression_id: the id of the level_progression 354 | :return: a single :class:`.models.LevelProgression` 355 | """ 356 | url = self.url_builder.build_wk_url( 357 | constants.LEVEL_PROGRESSIONS_ENDPOINT, resource_id=level_progression_id 358 | ) 359 | return self.authorized_request_maker(url) 360 | 361 | def level_progressions(self, ids=None, updated_after=None, fetch_all=False): 362 | """ 363 | Retrieve all :class:`.models.LevelProgression` for a given user. 364 | 365 | :param bool fetch_all: if set to True, instead of fetching only first page of results, will fetch them all. 366 | :param int[] ids: Return only results with the given IDs 367 | :param datetime updated_after: Return results which have been updated after the timestamp 368 | :return: An iterator over all :class:`.models.LevelProgression` for a given user. 369 | """ 370 | url = self.url_builder.build_wk_url( 371 | constants.LEVEL_PROGRESSIONS_ENDPOINT, parameters=locals() 372 | ) 373 | 374 | return self._wrap_collection_in_iterator( 375 | self.authorized_request_maker(url), fetch_all 376 | ) 377 | 378 | def reset(self, reset_id): 379 | """ 380 | Get a single :class:`.models.Reset` by its known id 381 | 382 | :param reset_id: the id of the reset 383 | :return: a single :class:`.models.Reset` 384 | """ 385 | response = requests.get( 386 | self.url_builder.build_wk_url( 387 | constants.REVIEWS_ENDPOINT, resource_id=reset_id 388 | ), 389 | headers=self.headers, 390 | ) 391 | return self._serialize_wanikani_response(response) 392 | 393 | def resets(self, ids=None, updated_after=None, fetch_all=False): 394 | """ 395 | Retrieve information for all resets the user has performed on Wanikani. 396 | 397 | :param bool fetch_all: if set to True, instead of fetching only first page of results, will fetch them all. 398 | :param int[] ids: Return only results with the given IDs 399 | :param datetime updated_after: Return results which have been updated after the timestamp 400 | :return: An iterator over all :class:`.models.Reset` for a given user. 401 | """ 402 | response = requests.get( 403 | self.url_builder.build_wk_url( 404 | constants.RESETS_ENDPOINT, parameters=locals() 405 | ), 406 | headers=self.headers, 407 | ) 408 | return self._wrap_collection_in_iterator( 409 | self._serialize_wanikani_response(response), fetch_all 410 | ) 411 | 412 | def _serialize_wanikani_response(self, response): 413 | if response.status_code == 200: 414 | json = response.json() 415 | return models.factory(json, client=self) 416 | elif response.status_code == 401: 417 | raise InvalidWanikaniApiKeyException( 418 | "[{}] is not a valid API key!".format(self.v2_api_key) 419 | ) 420 | 421 | def _wrap_collection_in_iterator(self, resource, fetch_all): 422 | return Iterator( 423 | current_page=resource, 424 | api_request=self.authorized_request_maker, 425 | fetch_all=fetch_all, 426 | ) 427 | -------------------------------------------------------------------------------- /wanikani_api/models.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import operator 3 | import pprint 4 | 5 | import dateutil.parser 6 | 7 | from wanikani_api import constants 8 | from wanikani_api.exceptions import UnknownResourceException 9 | 10 | 11 | class Resource: 12 | def __init__(self, json_data, *args, **kwargs): 13 | self.resource = json_data["object"] 14 | self._raw = json_data 15 | self.url = json_data["url"] 16 | self.data_updated_at = parse8601(json_data["data_updated_at"]) 17 | # Some Resources do not have IDs. 18 | self.id = ( 19 | None 20 | if self.resource in constants.RESOURCES_WITHOUT_IDS 21 | else json_data["id"] 22 | ) 23 | self._resource = json_data["data"] 24 | 25 | def raw_json(self): 26 | return pprint.pformat(self._raw) 27 | 28 | 29 | class Iterator: 30 | def __init__(self, current_page, api_request, max_results=None, fetch_all=False): 31 | self.current_page = current_page 32 | self.api_request = api_request 33 | self.max_results = max_results 34 | self.yielded_count = 0 35 | self.pages = [current_page] 36 | if current_page: 37 | self.per_page = current_page.per_page 38 | if fetch_all: 39 | self.fetch_all_pages() 40 | 41 | def _keep_iterating(self): 42 | return ( 43 | self.current_page is not None 44 | and self.max_results 45 | and self.yielded_count >= self.max_results 46 | ) 47 | 48 | def fetch_next_page(self): 49 | if self.current_page.next_page_url is not None: 50 | self.pages.append(self.api_request(self.current_page.next_page_url)) 51 | self.current_page = self.pages[-1] 52 | 53 | def fetch_all_pages(self): 54 | while self.current_page.next_page_url is not None: 55 | self.fetch_next_page() 56 | 57 | def __iter__(self): 58 | return iter([item for page in self.pages for item in page]) 59 | 60 | def __getitem__(self, item): 61 | if isinstance(item, int): 62 | return self.pages[item // self.per_page][item % self.per_page] 63 | if isinstance(item, slice): 64 | return [self[i] for i in range(*item.indices(len(self)))] 65 | 66 | def __len__(self): 67 | return functools.reduce(operator.add, [len(page) for page in self.pages]) 68 | 69 | 70 | class Page(Resource): 71 | resource = "collection" 72 | 73 | def __init__(self, json_data, *args, **kwargs): 74 | super().__init__(json_data, *args, **kwargs) 75 | self.client = kwargs.get("client") 76 | self.next_page_url = json_data["pages"]["next_url"] 77 | self.previous_page_url = json_data["pages"]["previous_url"] 78 | self.total_count = json_data["total_count"] 79 | self.per_page = json_data["pages"]["per_page"] 80 | self.data = [factory(datum, client=self.client) for datum in json_data["data"]] 81 | 82 | def __iter__(self): 83 | return iter(self.data) 84 | 85 | def __getitem__(self, item): 86 | if isinstance(item, int): 87 | return self.data[item] 88 | elif isinstance(item, slice): 89 | return [self.data[i] for i in range(*item.indices(len(self)))] 90 | 91 | def __len__(self): 92 | return len(self.data) 93 | 94 | 95 | class Subjectable: 96 | """ 97 | A Mixin allowing a model to quickly fetch related subjects. 98 | 99 | Any resource which inherits Subjectable must have either `subject_id` or `subject_ids` as an attribute. 100 | """ 101 | 102 | def __init__(self, *args, **kwargs): 103 | if "client" not in kwargs: 104 | raise ValueError("Subjectable models expect an instance of Client!") 105 | self.client = kwargs.get("client") 106 | self._subjects = None 107 | self._subject = None 108 | 109 | @property 110 | def subject(self): 111 | if self._subject: 112 | return self._subject 113 | elif hasattr(self, "subject_id"): 114 | self._subject = self.client.subject(self.subject_id) 115 | return self._subject 116 | else: 117 | raise AttributeError("no attribute named subject!") 118 | 119 | @property 120 | def subjects(self): 121 | if self._subjects: 122 | return self._subjects 123 | elif hasattr(self, "subject_ids"): 124 | self._subjects = self.client.subjects(ids=self.subject_ids) 125 | return self._subjects 126 | else: 127 | raise AttributeError("no attribute named subjects!") 128 | 129 | 130 | class Preferences(): 131 | def __init__(self, preferences_json): 132 | self.default_voice_actor_id = preferences_json.get("default_voice_actor_id") 133 | self.lessons_batch_size= preferences_json["lessons_batch_size"] 134 | self.lessons_autoplay_audio = preferences_json["lessons_autoplay_audio"] 135 | self.reviews_autoplay_audio = preferences_json["reviews_autoplay_audio"] 136 | self.lessons_presentation_order = preferences_json["lessons_presentation_order"] 137 | self.reviews_display_srs_indicator = preferences_json["reviews_display_srs_indicator"] 138 | 139 | class UserInformation(Resource): 140 | """ 141 | This is a simple container for information returned from the ``/user/`` endpoint. This is all information related to 142 | the user. 143 | """ 144 | 145 | resource = "user" 146 | 147 | def __init__(self, json_data, *args, **kwargs): 148 | super().__init__(json_data, *args, **kwargs) 149 | self.username = self._resource["username"] #: username 150 | self.level = self._resource["level"] #: current wanikani level 151 | self.subscription = Subscription(self._resource['subscription']) #: maximum level granted by subscription. 152 | self.profile_url = self._resource["profile_url"] #: Link to user's profile. 153 | self.started_at = parse8601( 154 | self._resource["started_at"] 155 | ) #: datetime at which the user signed up. 156 | self.current_vacation_started_at = parse8601( 157 | self._resource["current_vacation_started_at"] 158 | ) #: datetime at which vacation was enabled on wanikani. 159 | self.preferences = Preferences(self._resource["preferences"]) 160 | 161 | def __str__(self): 162 | return "UserInformation{{ username:{}, level:{}, profile_url:{} started_at:{}, current_vacation_started_at:{} }}".format( 163 | self.username, 164 | self.level, 165 | self.profile_url, 166 | self.started_at, 167 | self.current_vacation_started_at, 168 | ) 169 | 170 | 171 | class Subject(Resource): 172 | """ 173 | This is the base Subject for Wanikani. This contains information common to Kanji, Vocabulary, and Radicals. 174 | """ 175 | 176 | def __init__(self, json_data, *args, **kwargs): 177 | super().__init__(json_data, *args, **kwargs) 178 | resource_data = json_data["data"] 179 | self.level = resource_data["level"] #: The level of the subject. 180 | self.created_at = parse8601( 181 | resource_data["created_at"] 182 | ) #: The date at which the Subject was created originally on Wanikani. 183 | self.characters = resource_data[ 184 | "characters" 185 | ] #: The actual japanese kanji/radical symbol such as 女 186 | self.meanings = [ 187 | Meaning(meaning_json) for meaning_json in resource_data["meanings"] 188 | ] #: A list of :class:`.models.Meaning` for this subject. 189 | self.document_url = resource_data[ 190 | "document_url" 191 | ] #: The direct URL where the subject can be found on Wanikani 192 | self.hidden_at = resource_data[ 193 | "hidden_at" 194 | ] #: When Wanikani removes a subject, they seem to instead set it to hidden, for backwards compatibilty with clients. 195 | self.auxiliary_meanings = [ 196 | AuxiliaryMeaning(auxiliary_meaning_json) for auxiliary_meaning_json in resource_data["auxiliary_meanings"] 197 | ] 198 | self.slug = resource_data["slug"] 199 | self.meaning_mnemonic = resource_data["meaning_mnemonic"] 200 | self.lesson_position = resource_data["lesson_position"] 201 | self.spaced_repitition_system_id = resource_data["spaced_repetition_system_id"] 202 | 203 | def __str__(self) -> str: 204 | return f"{['['+meaning.meaning+']' if meaning.primary else meaning.meaning for meaning in self.meanings]}:{[character for character in self.characters] if self.characters else 'UNAVAILABLE'}" 205 | 206 | 207 | class Radical(Subject): 208 | """ 209 | A model for the Radical object. 210 | """ 211 | 212 | resource = "radical" 213 | 214 | def __init__(self, json_data, *args, **kwargs): 215 | super().__init__(json_data, *args, **kwargs) 216 | self.character_images = ( 217 | self._resource["character_images"] 218 | if "character_images" in self._resource.keys() 219 | else None 220 | ) #: A list of dictionaries, each containing a bunch of information related to a single character image. 221 | self.amalgamation_subject_ids = self._resource[ 222 | "amalgamation_subject_ids" 223 | ] #: IDs for various other :class:`.models.Subject` for which this radical is a component. 224 | 225 | def __str__(self) -> str: 226 | return f"Radical: {[meaning.meaning for meaning in self.meanings]}:{[character for character in self.characters] if self.characters else 'UNAVAILABLE'}" 227 | 228 | 229 | 230 | 231 | class Vocabulary(Subject): 232 | """ 233 | A model for the Vocabulary Resource 234 | """ 235 | 236 | resource = "vocabulary" 237 | 238 | def __init__(self, json_data, *args, **kwargs): 239 | super().__init__(json_data, *args, **kwargs) 240 | self.parts_of_speech = self._resource[ 241 | "parts_of_speech" 242 | ] #: A list of strings, each of which is a part of speech. 243 | self.component_subject_ids = self._resource[ 244 | "component_subject_ids" 245 | ] #: List of IDs for :class"`.models.Kanji` which make up this vocabulary. 246 | self.readings = [ 247 | Reading(reading_json) for reading_json in self._resource["readings"] 248 | ] #: A list of :class:`.models.Reading` related to this Vocabulary. 249 | self.reading_mnemonic = self._resource["reading_mnemonic"] 250 | 251 | self.context_sentences = [ 252 | ContextSentence(context_sentence_json) for context_sentence_json in self._resource["context_sentences"] 253 | ] 254 | self.pronunciation_audios = [ 255 | PronunciationAudio(pronunciation_json) for pronunciation_json in self._resource["pronunciation_audios"] 256 | ] 257 | 258 | def __str__(self): 259 | return f"Vocabulary: {super(Vocabulary, self).__str__()}" 260 | 261 | 262 | class Kanji(Subject): 263 | """ 264 | A model for the Kanji Resource 265 | """ 266 | 267 | resource = "kanji" 268 | 269 | def __init__(self, json_data, *args, **kwargs): 270 | super().__init__(json_data, *args, **kwargs) 271 | self.amalgamation_subject_ids = self._resource[ 272 | "amalgamation_subject_ids" 273 | ] #: A list of IDs for the related :class:`.models.Vocabulary` which this Kanji is a component in. 274 | self.component_subject_ids = self._resource[ 275 | "component_subject_ids" 276 | ] #: A list of IDs for the related :class:`.models.Radical` which combine to make this kanji 277 | self.readings = [ 278 | Reading(reading_json) for reading_json in self._resource["readings"] 279 | ] #: A list of :class:`.models.Reading` related to this Vocabulary. 280 | self.reading_mnemonic = self._resource["reading_mnemonic"] 281 | self.meaning_hint = self._resource["meaning_hint"] 282 | self.reading_hint = self._resource["reading_hint"] 283 | 284 | def __str__(self): 285 | return f"Kanji: {super(Kanji, self).__str__()}" 286 | 287 | class AuxiliaryMeaning: 288 | """ 289 | Simple data class for handling auxiliary meanings 290 | """ 291 | def __init__(self, auxiliary_meaning_json): 292 | self.meaning = auxiliary_meaning_json["meaning"] 293 | self.type = auxiliary_meaning_json["type"] 294 | 295 | def __str__(self) -> str: 296 | return f"{self.meaning}({type})" 297 | 298 | class Subscription: 299 | def __init__(self, subscription_json): 300 | self.active = subscription_json["active"] 301 | self.type = subscription_json["type"] 302 | self.max_level_granted = subscription_json["max_level_granted"] 303 | self.period_ends_at = parse8601(subscription_json["period_ends_at"]) 304 | 305 | def __str__(self) -> str: 306 | return f"{'active' if self.active else 'inactive'}:{self.type}:{self.max_level_granted}:{self.period_ends_at}" 307 | 308 | class Meaning: 309 | """ 310 | Simple class holding information about a given meaning of a vocabulary/Kanji 311 | """ 312 | 313 | def __init__(self, meaning_json): 314 | self.meaning = meaning_json["meaning"] #: The english meaning of a Subject. 315 | self.primary = meaning_json[ 316 | "primary" 317 | ] #: Wether or not the meaning is considered to be the main one. 318 | self.accepted_answer = meaning_json[ 319 | "accepted_answer" 320 | ] #: Whether or not this answer is accepted during reviews in Wanikani. 321 | 322 | def __str__(self) -> str: 323 | return self.meaning 324 | 325 | 326 | class PronunciationAudio: 327 | """ 328 | This class holds the link to the pronunciation audio, as well as the related metadata. 329 | """ 330 | class PronunciationMetadata: 331 | def __init__(self, metadata_json): 332 | self.gender = metadata_json["gender"] 333 | self.source_id = metadata_json["source_id"] 334 | self.pronunciation = metadata_json["pronunciation"] 335 | self.voice_actor_id = metadata_json["voice_actor_id"] 336 | self.voice_actor_name = metadata_json["voice_actor_name"] 337 | self.voice_description = metadata_json["voice_description"] 338 | def __init__(self, pronunciation_audio): 339 | self.url = pronunciation_audio["url"] 340 | self.content_type = pronunciation_audio["content_type"] 341 | self.metadata = self.PronunciationMetadata(pronunciation_audio["metadata"]) 342 | 343 | 344 | 345 | 346 | class ContextSentence: 347 | """ 348 | Class to hold english and japanese context sentence information 349 | """ 350 | def __init__(self, sentence_json): 351 | self.english = sentence_json["en"] 352 | self.japanese = sentence_json["ja"] 353 | 354 | 355 | class Reading: 356 | """ 357 | Simple class holding information about a given reading of a vocabulary/kanji 358 | """ 359 | 360 | def __init__(self, meaning_json): 361 | #: the actual かな for the reading. 362 | self.reading = meaning_json["reading"] 363 | self.primary = meaning_json["primary"] #: Whether this is the primary reading. 364 | self.accepted_answer = meaning_json[ 365 | "accepted_answer" 366 | ] #: Whether this answer is accepted as correct by Wanikani during review. 367 | 368 | 369 | class Assignment(Resource, Subjectable): 370 | """ 371 | Simple class holding information about Assignmetns. 372 | """ 373 | 374 | resource = "assignment" 375 | 376 | def __init__(self, json_data, *args, **kwargs): 377 | super().__init__(json_data, *args, **kwargs) 378 | Subjectable.__init__(self, *args, **kwargs) 379 | self.created_at = parse8601(self._resource["created_at"]) 380 | self.subject_id = self._resource["subject_id"] 381 | self.subject_type = self._resource["subject_type"] 382 | self.srs_stage = self._resource["srs_stage"] 383 | self.unlocked_at = parse8601(self._resource["unlocked_at"]) 384 | self.started_at = parse8601(self._resource["started_at"]) 385 | self.passed_at = parse8601(self._resource["passed_at"]) 386 | self.burned_at = parse8601(self._resource["burned_at"]) 387 | self.available_at = parse8601(self._resource["available_at"]) 388 | self.resurrected_at_at = parse8601(self._resource["resurrected_at"]) 389 | self.hidden = self._resource["hidden"] 390 | 391 | 392 | class Reset(Resource): 393 | """ 394 | Simple model holding resource information 395 | """ 396 | 397 | resource = "reset" 398 | 399 | def __init__(self, json_data, *args, **kwargs): 400 | super().__init__(json_data, *args, **kwargs) 401 | self.created_at = parse8601(self._resource["created_at"]) 402 | self.original_level = self._resource["original_level"] 403 | self.target_level = self._resource["target_level"] 404 | self.confirmed_at = parse8601(self._resource["confirmed_at"]) 405 | 406 | 407 | class ReviewStatistic(Resource): 408 | """ 409 | Simple model holding ReviewStatistic Information 410 | """ 411 | 412 | resource = "review_statistic" 413 | 414 | def __init__(self, json_data, *args, **kwargs): 415 | super().__init__(json_data, *args, **kwargs) 416 | self.created_at = parse8601(self._resource["created_at"]) 417 | self.subject_id = self._resource["subject_id"] 418 | self.subject_type = self._resource["subject_type"] 419 | self.meaning_correct = self._resource["meaning_correct"] 420 | self.meaning_incorrect = self._resource["meaning_incorrect"] 421 | self.meaning_max_streak = self._resource["meaning_max_streak"] 422 | self.meaning_current_streak = self._resource["meaning_current_streak"] 423 | self.reading_correct = self._resource["reading_correct"] 424 | self.reading_incorrect = self._resource["reading_incorrect"] 425 | self.reading_max_streak = self._resource["reading_max_streak"] 426 | self.reading_current_streak = self._resource["reading_current_streak"] 427 | self.percentage_correct = self._resource["percentage_correct"] 428 | self.hidden = self._resource["hidden"] 429 | 430 | 431 | class StudyMaterial(Resource, Subjectable): 432 | """ 433 | Simple model holding information about Study Materials 434 | """ 435 | 436 | resource = "study_material" 437 | 438 | def __init__(self, json_data, *args, **kwargs): 439 | super().__init__(json_data, *args, **kwargs) 440 | Subjectable.__init__(self, *args, **kwargs) 441 | self.created_at = parse8601(self._resource["created_at"]) 442 | self.subject_id = self._resource["subject_id"] 443 | self.subject_type = self._resource["subject_type"] 444 | self.meaning_note = self._resource["meaning_note"] 445 | self.reading_note = self._resource["reading_note"] 446 | self.meaning_synonyms = self._resource["meaning_synonyms"] 447 | self.hidden = self._resource["hidden"] 448 | 449 | 450 | class Lessons(object): 451 | def __init__(self, json_data, *args, **kwargs): 452 | self.subject_ids = json_data["subject_ids"] 453 | self.available_at = parse8601(json_data["available_at"]) 454 | 455 | 456 | class UpcomingReview(Subjectable): 457 | def __init__(self, json_data, *args, **kwargs): 458 | super().__init__(json_data, *args, **kwargs) 459 | Subjectable.__init__(self, *args, **kwargs) 460 | self.subject_ids = json_data["subject_ids"] 461 | self.available_at = parse8601(json_data["available_at"]) 462 | 463 | 464 | class Summary(Resource): 465 | resource = "report" 466 | 467 | def __init__(self, json_data, *args, **kwargs): 468 | super().__init__(json_data, *args, **kwargs) 469 | self.client = kwargs.get("client") 470 | # Note that there is only ever one lesson object, as per this forum thread https://community.wanikani.com/t/api-v2-alpha-documentation/18987 471 | self.lessons = Lessons(self._resource["lessons"][0]) 472 | self.next_reviews_at = self._resource["next_reviews_at"] 473 | self.reviews = [ 474 | UpcomingReview(review_json, client=self.client) 475 | for review_json in self._resource["reviews"] 476 | ] 477 | 478 | 479 | class Review(Resource, Subjectable): 480 | resource = "review" 481 | 482 | def __init__(self, json_data, *args, **kwargs): 483 | super().__init__(json_data, *args, **kwargs) 484 | Subjectable.__init__(self, *args, **kwargs) 485 | self.created_at = parse8601(self._resource["created_at"]) 486 | self.assignment_id = self._resource["assignment_id"] 487 | self.subject_id = self._resource["subject_id"] 488 | self.starting_srs_stage = self._resource["starting_srs_stage"] 489 | self.ending_srs_stage = self._resource["ending_srs_stage"] 490 | self.incorrect_meaning_answers = self._resource["incorrect_meaning_answers"] 491 | self.incorrect_reading_answers = self._resource["incorrect_reading_answers"] 492 | 493 | 494 | class LevelProgression(Resource): 495 | resource = "level_progression" 496 | 497 | def __init__(self, json_data, *args, **kwargs): 498 | super().__init__(json_data, *args, **kwargs) 499 | self.created_at = parse8601(self._resource["created_at"]) 500 | self.level = self._resource["level"] 501 | self.unlocked_at = parse8601(self._resource["unlocked_at"]) 502 | self.started_at = parse8601(self._resource["started_at"]) 503 | self.passed_at = parse8601(self._resource["passed_at"]) 504 | self.completed_at = parse8601(self._resource["completed_at"]) 505 | 506 | 507 | def parse8601(time_field): 508 | if time_field: 509 | return dateutil.parser.parse(time_field) 510 | else: 511 | return None 512 | 513 | 514 | resources = { 515 | UserInformation.resource: UserInformation, 516 | Assignment.resource: Assignment, 517 | Review.resource: Review, 518 | ReviewStatistic.resource: ReviewStatistic, 519 | LevelProgression.resource: LevelProgression, 520 | StudyMaterial.resource: StudyMaterial, 521 | Reset.resource: Reset, 522 | Kanji.resource: Kanji, 523 | Vocabulary.resource: Vocabulary, 524 | Radical.resource: Radical, 525 | Summary.resource: Summary, 526 | Page.resource: Page, 527 | } 528 | 529 | 530 | def factory(resource_json, *args, **kwargs): 531 | try: 532 | return resources[resource_json["object"]](resource_json, *args, **kwargs) 533 | except KeyError: 534 | raise UnknownResourceException( 535 | "We have no clue how to handle resource of type: {}".format( 536 | resource_json["object"] 537 | ) 538 | ) 539 | -------------------------------------------------------------------------------- /tests/utils/response_mocks.py: -------------------------------------------------------------------------------- 1 | USER_INFORMATION = { 2 | "object": "user", 3 | "url": "https://api.wanikani.com/v2/user", 4 | "data_updated_at": "2020-05-01T05:20:41.769053Z", 5 | "data": { 6 | "id": "7a18daeb-4067-4e77-b0ea-230c7c347ea8", 7 | "username": "Tadgh11", 8 | "level": 12, 9 | "profile_url": "https://www.wanikani.com/users/Tadgh11", 10 | "started_at": "2013-07-09T12:02:54.952786Z", 11 | "subscription": { 12 | "active": True, 13 | "type": "lifetime", 14 | "max_level_granted": 60, 15 | "period_ends_at": None 16 | }, 17 | "current_vacation_started_at": None, 18 | "preferences": { 19 | "lessons_batch_size": 5, 20 | "lessons_autoplay_audio": True, 21 | "reviews_autoplay_audio": False, 22 | "lessons_presentation_order": "ascending_level_then_subject", 23 | "reviews_display_srs_indicator": True 24 | } 25 | } 26 | } 27 | 28 | SUBJECT = { 29 | "id": 2467, 30 | "object": "vocabulary", 31 | "url": "https://api.wanikani.com/v2/subjects/2467", 32 | "data_updated_at": "2018-05-21T21:52:43.041390Z", 33 | "data": { 34 | "created_at": "2012-02-28T08:04:47.000000Z", 35 | "level": 1, 36 | "slug": "一", 37 | "hidden_at": None, 38 | "document_url": "https://www.wanikani.com/vocabulary/%E4%B8%80", 39 | "characters": "一", 40 | "meanings": [{"meaning": "One", "primary": True, "accepted_answer": True}], 41 | "readings": [{"primary": True, "reading": "いち", "accepted_answer": True}], 42 | "parts_of_speech": ["numeral"], 43 | "component_subject_ids": [440], 44 | "auxiliary_meanings": [], 45 | }, 46 | } 47 | 48 | SINGLE_SUBJECT = { 49 | "id": 1, 50 | "object": "radical", 51 | "url": "https://api.wanikani.com/v2/subjects/1", 52 | "data_updated_at": "2022-07-24T01:22:18.573676Z", 53 | "data": { 54 | "created_at": "2012-02-27T18:08:16.000000Z", 55 | "level": 1, 56 | "slug": "ground", 57 | "hidden_at": None, 58 | "document_url": "https://www.wanikani.com/radicals/ground", 59 | "characters": "一", 60 | "character_images": [ 61 | { 62 | "url": "https://files.wanikani.com/a7w32gazaor51ii0fbtxzk0wpmpc", 63 | "metadata": { 64 | "inline_styles": False 65 | }, 66 | "content_type": "image/svg+xml" 67 | }, 68 | { 69 | "url": "https://files.wanikani.com/fxufa23ht9uh0tkedo1zx5jemaio", 70 | "metadata": { 71 | "inline_styles": True 72 | }, 73 | "content_type": "image/svg+xml" 74 | }, 75 | { 76 | "url": "https://files.wanikani.com/4lxmimfbwuvl07s11dq0f9til0mb", 77 | "metadata": { 78 | "color": "#000000", 79 | "dimensions": "1024x1024", 80 | "style_name": "original" 81 | }, 82 | "content_type": "image/png" 83 | }, 84 | { 85 | "url": "https://files.wanikani.com/3n3dlzyjjgou47qb4h4uewghcfcx", 86 | "metadata": { 87 | "color": "#000000", 88 | "dimensions": "1024x1024", 89 | "style_name": "1024px" 90 | }, 91 | "content_type": "image/png" 92 | }, 93 | { 94 | "url": "https://files.wanikani.com/9d5fax4vrjp28vms1jb11ouu37vi", 95 | "metadata": { 96 | "color": "#000000", 97 | "dimensions": "512x512", 98 | "style_name": "512px" 99 | }, 100 | "content_type": "image/png" 101 | }, 102 | { 103 | "url": "https://files.wanikani.com/gfwzjl41i5v5oiwrsjz5zz957nww", 104 | "metadata": { 105 | "color": "#000000", 106 | "dimensions": "256x256", 107 | "style_name": "256px" 108 | }, 109 | "content_type": "image/png" 110 | }, 111 | { 112 | "url": "https://files.wanikani.com/m79ver1yfujpkcfa0bo5tcueuxk3", 113 | "metadata": { 114 | "color": "#000000", 115 | "dimensions": "128x128", 116 | "style_name": "128px" 117 | }, 118 | "content_type": "image/png" 119 | }, 120 | { 121 | "url": "https://files.wanikani.com/gcqkjhbw9aguieat8yrqxz09qszn", 122 | "metadata": { 123 | "color": "#000000", 124 | "dimensions": "64x64", 125 | "style_name": "64px" 126 | }, 127 | "content_type": "image/png" 128 | }, 129 | { 130 | "url": "https://files.wanikani.com/7czfgjlgsjxx8sndvfkezts6ugj1", 131 | "metadata": { 132 | "color": "#000000", 133 | "dimensions": "32x32", 134 | "style_name": "32px" 135 | }, 136 | "content_type": "image/png" 137 | } 138 | ], 139 | "meanings": [ 140 | { 141 | "meaning": "Ground", 142 | "primary": True, 143 | "accepted_answer": True 144 | } 145 | ], 146 | "auxiliary_meanings": [], 147 | "amalgamation_subject_ids": [ 148 | 440, 149 | 449, 150 | 450, 151 | 451, 152 | 468, 153 | 488, 154 | 531, 155 | 533, 156 | 568, 157 | 590, 158 | 609, 159 | 633, 160 | 635, 161 | 709, 162 | 710, 163 | 724, 164 | 783, 165 | 808, 166 | 885, 167 | 913, 168 | 932, 169 | 965, 170 | 971, 171 | 1000, 172 | 1020, 173 | 1085, 174 | 1113, 175 | 1119, 176 | 1126, 177 | 1137, 178 | 1178, 179 | 1198, 180 | 1241, 181 | 1249, 182 | 1326, 183 | 1340, 184 | 1367, 185 | 1372, 186 | 1376, 187 | 1379, 188 | 1428, 189 | 1431, 190 | 1463, 191 | 1491, 192 | 1506, 193 | 1521, 194 | 1547, 195 | 1559, 196 | 1591, 197 | 1655, 198 | 1769, 199 | 1851, 200 | 1852, 201 | 1855, 202 | 1868, 203 | 1869, 204 | 1888, 205 | 1970, 206 | 2091, 207 | 2104, 208 | 2128, 209 | 2138, 210 | 2148, 211 | 2171, 212 | 2172, 213 | 2182, 214 | 2212, 215 | 2277, 216 | 2334, 217 | 2375, 218 | 2419, 219 | 2437 220 | ], 221 | "meaning_mnemonic": "This radical consists of a single, horizontal stroke. What's the biggest, single, horizontal stroke? That's the ground. Look at the ground, look at this radical, now look at the ground again. Kind of the same, right?", 222 | "lesson_position": 0, 223 | "spaced_repetition_system_id": 2 224 | } 225 | } 226 | 227 | EMPTY_SUBJECTS_PAGE = { 228 | "object": "collection", 229 | "url": "https://api.wanikani.com/v2/subjects?ids=1%2C2%2C3&slugs=abc%2C123&types=vocabulary", 230 | "pages": {"per_page": 1000, "next_url": None, "previous_url": None}, 231 | "total_count": 0, 232 | "data_updated_at": None, 233 | "data": [], 234 | } 235 | 236 | SUBJECTS_PAGE = { 237 | "object": "collection", 238 | "url": "https://api.wanikani.com/v2/subjects", 239 | "pages": {"per_page": 1000, "next_url": None, "previous_url": None}, 240 | "total_count": 3, 241 | "data_updated_at": "2018-07-05T22:22:07.129381Z", 242 | "data": [ 243 | { 244 | "id": 1, 245 | "object": "radical", 246 | "url": "https://api.wanikani.com/v2/subjects/1", 247 | "data_updated_at": "2022-07-24T01:22:18.573676Z", 248 | "data": { 249 | "created_at": "2012-02-27T18:08:16.000000Z", 250 | "level": 1, 251 | "slug": "ground", 252 | "hidden_at": None, 253 | "document_url": "https://www.wanikani.com/radicals/ground", 254 | "characters": "一", 255 | "character_images": [ 256 | { 257 | "url": "https://files.wanikani.com/a7w32gazaor51ii0fbtxzk0wpmpc", 258 | "metadata": { 259 | "inline_styles": False 260 | }, 261 | "content_type": "image/svg+xml" 262 | }, 263 | { 264 | "url": "https://files.wanikani.com/fxufa23ht9uh0tkedo1zx5jemaio", 265 | "metadata": { 266 | "inline_styles": True 267 | }, 268 | "content_type": "image/svg+xml" 269 | }, 270 | { 271 | "url": "https://files.wanikani.com/4lxmimfbwuvl07s11dq0f9til0mb", 272 | "metadata": { 273 | "color": "#000000", 274 | "dimensions": "1024x1024", 275 | "style_name": "original" 276 | }, 277 | "content_type": "image/png" 278 | }, 279 | { 280 | "url": "https://files.wanikani.com/3n3dlzyjjgou47qb4h4uewghcfcx", 281 | "metadata": { 282 | "color": "#000000", 283 | "dimensions": "1024x1024", 284 | "style_name": "1024px" 285 | }, 286 | "content_type": "image/png" 287 | }, 288 | { 289 | "url": "https://files.wanikani.com/9d5fax4vrjp28vms1jb11ouu37vi", 290 | "metadata": { 291 | "color": "#000000", 292 | "dimensions": "512x512", 293 | "style_name": "512px" 294 | }, 295 | "content_type": "image/png" 296 | }, 297 | { 298 | "url": "https://files.wanikani.com/gfwzjl41i5v5oiwrsjz5zz957nww", 299 | "metadata": { 300 | "color": "#000000", 301 | "dimensions": "256x256", 302 | "style_name": "256px" 303 | }, 304 | "content_type": "image/png" 305 | }, 306 | { 307 | "url": "https://files.wanikani.com/m79ver1yfujpkcfa0bo5tcueuxk3", 308 | "metadata": { 309 | "color": "#000000", 310 | "dimensions": "128x128", 311 | "style_name": "128px" 312 | }, 313 | "content_type": "image/png" 314 | }, 315 | { 316 | "url": "https://files.wanikani.com/gcqkjhbw9aguieat8yrqxz09qszn", 317 | "metadata": { 318 | "color": "#000000", 319 | "dimensions": "64x64", 320 | "style_name": "64px" 321 | }, 322 | "content_type": "image/png" 323 | }, 324 | { 325 | "url": "https://files.wanikani.com/7czfgjlgsjxx8sndvfkezts6ugj1", 326 | "metadata": { 327 | "color": "#000000", 328 | "dimensions": "32x32", 329 | "style_name": "32px" 330 | }, 331 | "content_type": "image/png" 332 | } 333 | ], 334 | "meanings": [ 335 | { 336 | "meaning": "Ground", 337 | "primary": True, 338 | "accepted_answer": True 339 | } 340 | ], 341 | "auxiliary_meanings": [], 342 | "amalgamation_subject_ids": [ 343 | 440, 344 | 449, 345 | 450, 346 | 451, 347 | 468, 348 | 488, 349 | 531, 350 | 533, 351 | 568, 352 | 590, 353 | 609, 354 | 633, 355 | 635, 356 | 709, 357 | 710, 358 | 724, 359 | 783, 360 | 808, 361 | 885, 362 | 913, 363 | 932, 364 | 965, 365 | 971, 366 | 1000, 367 | 1020, 368 | 1085, 369 | 1113, 370 | 1119, 371 | 1126, 372 | 1137, 373 | 1178, 374 | 1198, 375 | 1241, 376 | 1249, 377 | 1326, 378 | 1340, 379 | 1367, 380 | 1372, 381 | 1376, 382 | 1379, 383 | 1428, 384 | 1431, 385 | 1463, 386 | 1491, 387 | 1506, 388 | 1521, 389 | 1547, 390 | 1559, 391 | 1591, 392 | 1655, 393 | 1769, 394 | 1851, 395 | 1852, 396 | 1855, 397 | 1868, 398 | 1869, 399 | 1888, 400 | 1970, 401 | 2091, 402 | 2104, 403 | 2128, 404 | 2138, 405 | 2148, 406 | 2171, 407 | 2172, 408 | 2182, 409 | 2212, 410 | 2277, 411 | 2334, 412 | 2375, 413 | 2419, 414 | 2437 415 | ], 416 | "meaning_mnemonic": "This radical consists of a single, horizontal stroke. What's the biggest, single, horizontal stroke? That's the ground. Look at the ground, look at this radical, now look at the ground again. Kind of the same, right?", 417 | "lesson_position": 0, 418 | "spaced_repetition_system_id": 2 419 | } 420 | }, 421 | { 422 | "id": 2, 423 | "object": "kanji", 424 | "url": "https://api.wanikani.com/v2/subjects/534", 425 | "data_updated_at": "2022-05-30T19:39:55.744373Z", 426 | "data": { 427 | "created_at": "2012-03-02T02:11:55.000000Z", 428 | "level": 4, 429 | "slug": "央", 430 | "hidden_at": None, 431 | "document_url": "https://www.wanikani.com/kanji/%E5%A4%AE", 432 | "characters": "央", 433 | "meanings": [ 434 | { 435 | "meaning": "Center", 436 | "primary": True, 437 | "accepted_answer": True 438 | }, 439 | { 440 | "meaning": "Centre", 441 | "primary": False, 442 | "accepted_answer": True 443 | } 444 | ], 445 | "auxiliary_meanings": [], 446 | "readings": [ 447 | { 448 | "type": "onyomi", 449 | "primary": True, 450 | "reading": "おう", 451 | "accepted_answer": True 452 | } 453 | ], 454 | "component_subject_ids": [ 455 | 18, 456 | 29 457 | ], 458 | "amalgamation_subject_ids": [ 459 | 2726 460 | ], 461 | "visually_similar_subject_ids": [], 462 | "meaning_mnemonic": "If someone has a big head you have to try to focus on the center of it when you talk to them.", 463 | "meaning_hint": "You don't want to get caught with your eyes wandering all over their big head. Stare right at the center of their face. Ah geez, their eyes are kinda far away from here. You can really only see their nose...", 464 | "reading_mnemonic": "This is the center of the 's (おう) head so you have to be extra careful not to look anywhere else.", 465 | "reading_hint": "The will definitely cut off your normal-sized head if he sees you looking all over his big head and not at the center.", 466 | "lesson_position": 0, 467 | "spaced_repetition_system_id": 1 468 | } 469 | }, 470 | { 471 | "id": 3, 472 | "object": "vocabulary", 473 | "url": "https://api.wanikani.com/v2/subjects/2467", 474 | "data_updated_at": "2021-09-01T18:22:40.891504Z", 475 | "data": { 476 | "created_at": "2012-02-28T08:04:47.000000Z", 477 | "level": 1, 478 | "slug": "一", 479 | "hidden_at": None, 480 | "document_url": "https://www.wanikani.com/vocabulary/%E4%B8%80", 481 | "characters": "一", 482 | "meanings": [ 483 | { 484 | "meaning": "One", 485 | "primary": True, 486 | "accepted_answer": True 487 | } 488 | ], 489 | "auxiliary_meanings": [ 490 | { 491 | "type": "whitelist", 492 | "meaning": "1" 493 | } 494 | ], 495 | "readings": [ 496 | { 497 | "primary": True, 498 | "reading": "いち", 499 | "accepted_answer": True 500 | } 501 | ], 502 | "parts_of_speech": [ 503 | "numeral" 504 | ], 505 | "component_subject_ids": [ 506 | 440 507 | ], 508 | "meaning_mnemonic": "As is the case with most vocab words that consist of a single kanji, this vocab word has the same meaning as the kanji it parallels, which is one.", 509 | "reading_mnemonic": "When a vocab word is all alone and has no okurigana (hiragana attached to kanji) connected to it, it usually uses the kun'yomi reading. Numbers are an exception, however. When a number is all alone, with no kanji or okurigana, it is going to be the on'yomi reading, which you learned with the kanji. Just remember this exception for alone numbers and you'll be able to read future number-related vocab to come.", 510 | "context_sentences": [ 511 | { 512 | "en": "Let’s meet up once.", 513 | "ja": "一ど、あいましょう。" 514 | }, 515 | { 516 | "en": "First place was an American.", 517 | "ja": "一いはアメリカ人でした。" 518 | }, 519 | { 520 | "en": "I’m the weakest (person) in the world.", 521 | "ja": "ぼくはせかいで一ばんよわい。" 522 | } 523 | ], 524 | "pronunciation_audios": [ 525 | { 526 | "url": "https://files.wanikani.com/aeevlg446own3mcs6rye6k4wfq37", 527 | "metadata": { 528 | "gender": "female", 529 | "source_id": 21630, 530 | "pronunciation": "いち", 531 | "voice_actor_id": 1, 532 | "voice_actor_name": "Kyoko", 533 | "voice_description": "Tokyo accent" 534 | }, 535 | "content_type": "audio/ogg" 536 | }, 537 | { 538 | "url": "https://files.wanikani.com/w6loj76y9t8ppripy1eindt5dg3y", 539 | "metadata": { 540 | "gender": "female", 541 | "source_id": 21630, 542 | "pronunciation": "いち", 543 | "voice_actor_id": 1, 544 | "voice_actor_name": "Kyoko", 545 | "voice_description": "Tokyo accent" 546 | }, 547 | "content_type": "audio/webm" 548 | }, 549 | { 550 | "url": "https://files.wanikani.com/j5dy9yyxpzsywaxifq1c7yc3ctal", 551 | "metadata": { 552 | "gender": "male", 553 | "source_id": 2711, 554 | "pronunciation": "いち", 555 | "voice_actor_id": 2, 556 | "voice_actor_name": "Kenichi", 557 | "voice_description": "Tokyo accent" 558 | }, 559 | "content_type": "audio/webm" 560 | }, 561 | { 562 | "url": "https://files.wanikani.com/tfdkyds03nhrbs6to3e0q4avbg1u", 563 | "metadata": { 564 | "gender": "male", 565 | "source_id": 2711, 566 | "pronunciation": "いち", 567 | "voice_actor_id": 2, 568 | "voice_actor_name": "Kenichi", 569 | "voice_description": "Tokyo accent" 570 | }, 571 | "content_type": "audio/webm" 572 | }, 573 | { 574 | "url": "https://files.wanikani.com/vtyum09bj9tf2gle7i4ip04iao6s", 575 | "metadata": { 576 | "gender": "female", 577 | "source_id": 21630, 578 | "pronunciation": "いち", 579 | "voice_actor_id": 1, 580 | "voice_actor_name": "Kyoko", 581 | "voice_description": "Tokyo accent" 582 | }, 583 | "content_type": "audio/webm" 584 | }, 585 | { 586 | "url": "https://files.wanikani.com/5g89i8489j2joklaqdoy89rzhlqf", 587 | "metadata": { 588 | "gender": "male", 589 | "source_id": 2711, 590 | "pronunciation": "いち", 591 | "voice_actor_id": 2, 592 | "voice_actor_name": "Kenichi", 593 | "voice_description": "Tokyo accent" 594 | }, 595 | "content_type": "audio/ogg" 596 | }, 597 | { 598 | "url": "https://files.wanikani.com/jkdnvm82i2kl6my5ts67idq2qdc6", 599 | "metadata": { 600 | "gender": "male", 601 | "source_id": 2711, 602 | "pronunciation": "いち", 603 | "voice_actor_id": 2, 604 | "voice_actor_name": "Kenichi", 605 | "voice_description": "Tokyo accent" 606 | }, 607 | "content_type": "audio/mpeg" 608 | }, 609 | { 610 | "url": "https://files.wanikani.com/dwikzn441ltuq4evi7bmt5g3v7q2", 611 | "metadata": { 612 | "gender": "female", 613 | "source_id": 21630, 614 | "pronunciation": "いち", 615 | "voice_actor_id": 1, 616 | "voice_actor_name": "Kyoko", 617 | "voice_description": "Tokyo accent" 618 | }, 619 | "content_type": "audio/mpeg" 620 | } 621 | ], 622 | "lesson_position": 44, 623 | "spaced_repetition_system_id": 2 624 | } 625 | } 626 | ], 627 | } 628 | 629 | ASSIGNMENTS_PAGE = { 630 | "object": "collection", 631 | "url": "https://api.wanikani.com/v2/assignments", 632 | "pages": { 633 | "per_page": 500, 634 | "next_url": "https://api.wanikani.com/v2/assignments?page_after_id=62308815", 635 | "previous_url": None, 636 | }, 637 | "total_count": 3, 638 | "data_updated_at": "2018-06-30T16:40:52.513654Z", 639 | "data": [ 640 | { 641 | "id": 210688274, 642 | "object": "assignment", 643 | "url": "https://api.wanikani.com/v2/assignments/210688274", 644 | "data_updated_at": "2021-08-09T13:08:32.510479Z", 645 | "data": { 646 | "created_at": "2020-12-09T21:36:31.745922Z", 647 | "subject_id": 1, 648 | "subject_type": "vocabulary", 649 | "srs_stage": 8, 650 | "unlocked_at": "2020-12-09T21:36:31.739267Z", 651 | "started_at": "2020-12-09T23:24:56.021005Z", 652 | "passed_at": "2020-12-17T21:49:13.130832Z", 653 | "burned_at": None, 654 | "available_at": "2021-12-05T18:00:00.000000Z", 655 | "resurrected_at": None, 656 | "hidden": False 657 | } 658 | }, 659 | { 660 | "id": 210688287, 661 | "object": "assignment", 662 | "url": "https://api.wanikani.com/v2/assignments/210688287", 663 | "data_updated_at": "2021-07-16T00:37:57.764169Z", 664 | "data": { 665 | "created_at": "2020-12-09T21:36:31.913653Z", 666 | "subject_id": 2, 667 | "subject_type": "vocabulary", 668 | "srs_stage": 8, 669 | "unlocked_at": "2020-12-09T21:36:31.905996Z", 670 | "started_at": "2020-12-09T23:26:34.608307Z", 671 | "passed_at": "2020-12-19T21:31:47.175944Z", 672 | "burned_at": None, 673 | "available_at": "2021-11-12T23:00:00.000000Z", 674 | "resurrected_at": None, 675 | "hidden": False 676 | } 677 | }, 678 | { 679 | "id": 210688308, 680 | "object": "assignment", 681 | "url": "https://api.wanikani.com/v2/assignments/210688308", 682 | "data_updated_at": "2021-11-21T19:30:31.513789Z", 683 | "data": { 684 | "created_at": "2020-12-09T21:36:32.314040Z", 685 | "subject_id": 3, 686 | "subject_type": "vocabulary", 687 | "srs_stage": 9, 688 | "unlocked_at": "2020-12-09T21:36:32.301446Z", 689 | "started_at": "2020-12-09T23:27:17.833377Z", 690 | "passed_at": "2020-12-15T23:17:18.377500Z", 691 | "burned_at": "2021-11-21T19:30:31.465624Z", 692 | "available_at": None, 693 | "resurrected_at": None, 694 | "hidden": False 695 | } 696 | }, 697 | ], 698 | } 699 | 700 | REVIEW_STATISTICS_PAGE = { 701 | "object": "collection", 702 | "url": "https://api.wanikani.com/v2/review_statistics", 703 | "pages": { 704 | "per_page": 500, 705 | "next_url": "https://api.wanikani.com/v2/review_statistics?page_after_id=62308745", 706 | "previous_url": None, 707 | }, 708 | "total_count": 5, 709 | "data_updated_at": "2018-05-24T22:02:41.393482Z", 710 | "data": [ 711 | { 712 | "id": 85899, 713 | "object": "review_statistic", 714 | "url": "https://api.wanikani.com/v2/review_statistics/85899", 715 | "data_updated_at": "2018-01-03T00:08:22.469272Z", 716 | "data": { 717 | "created_at": "2017-04-15T14:53:56.818837Z", 718 | "subject_id": 1, 719 | "subject_type": "vocabulary", 720 | "meaning_correct": 13, 721 | "meaning_incorrect": 2, 722 | "meaning_max_streak": 7, 723 | "meaning_current_streak": 7, 724 | "reading_correct": 13, 725 | "reading_incorrect": 0, 726 | "reading_max_streak": 13, 727 | "reading_current_streak": 13, 728 | "percentage_correct": 93, 729 | "hidden": False, 730 | }, 731 | }, 732 | { 733 | "id": 86555, 734 | "object": "review_statistic", 735 | "url": "https://api.wanikani.com/v2/review_statistics/86555", 736 | "data_updated_at": "2018-02-19T23:02:25.114612Z", 737 | "data": { 738 | "created_at": "2017-04-15T14:50:51.503084Z", 739 | "subject_id": 2, 740 | "subject_type": "vocabulary", 741 | "meaning_correct": 11, 742 | "meaning_incorrect": 0, 743 | "meaning_max_streak": 11, 744 | "meaning_current_streak": 11, 745 | "reading_correct": 11, 746 | "reading_incorrect": 1, 747 | "reading_max_streak": 7, 748 | "reading_current_streak": 7, 749 | "percentage_correct": 96, 750 | "hidden": False, 751 | }, 752 | }, 753 | { 754 | "id": 86606, 755 | "object": "review_statistic", 756 | "url": "https://api.wanikani.com/v2/review_statistics/86606", 757 | "data_updated_at": "2018-02-19T22:46:09.166397Z", 758 | "data": { 759 | "created_at": "2017-04-24T15:17:28.712677Z", 760 | "subject_id": 3, 761 | "subject_type": "vocabulary", 762 | "meaning_correct": 8, 763 | "meaning_incorrect": 0, 764 | "meaning_max_streak": 8, 765 | "meaning_current_streak": 8, 766 | "reading_correct": 8, 767 | "reading_incorrect": 0, 768 | "reading_max_streak": 8, 769 | "reading_current_streak": 8, 770 | "percentage_correct": 100, 771 | "hidden": False, 772 | }, 773 | }, 774 | { 775 | "id": 86625, 776 | "object": "review_statistic", 777 | "url": "https://api.wanikani.com/v2/review_statistics/86625", 778 | "data_updated_at": "2018-02-19T23:54:40.912486Z", 779 | "data": { 780 | "created_at": "2017-04-24T15:17:29.061457Z", 781 | "subject_id": 1, 782 | "subject_type": "radical", 783 | "meaning_correct": 8, 784 | "meaning_incorrect": 0, 785 | "meaning_max_streak": 8, 786 | "meaning_current_streak": 8, 787 | "reading_correct": 1, 788 | "reading_incorrect": 0, 789 | "reading_max_streak": 1, 790 | "reading_current_streak": 1, 791 | "percentage_correct": 100, 792 | "hidden": False, 793 | }, 794 | }, 795 | { 796 | "id": 86891, 797 | "object": "review_statistic", 798 | "url": "https://api.wanikani.com/v2/review_statistics/86891", 799 | "data_updated_at": "2018-05-24T21:35:18.556752Z", 800 | "data": { 801 | "created_at": "2017-04-24T15:17:38.685804Z", 802 | "subject_id": 3, 803 | "subject_type": "vocabulary", 804 | "meaning_correct": 12, 805 | "meaning_incorrect": 1, 806 | "meaning_max_streak": 11, 807 | "meaning_current_streak": 11, 808 | "reading_correct": 12, 809 | "reading_incorrect": 1, 810 | "reading_max_streak": 9, 811 | "reading_current_streak": 3, 812 | "percentage_correct": 92, 813 | "hidden": False, 814 | }, 815 | }, 816 | ], 817 | } 818 | 819 | STUDY_MATERIALS_PAGE = { 820 | "object": "collection", 821 | "url": "https://api.wanikani.com/v2/study_materials", 822 | "pages": {"per_page": 500, "next_url": None, "previous_url": None}, 823 | "total_count": 3, 824 | "data_updated_at": "2018-02-20T21:23:31.246408Z", 825 | "data": [ 826 | { 827 | "id": 1539170, 828 | "object": "study_material", 829 | "url": "https://api.wanikani.com/v2/study_materials/1539170", 830 | "data_updated_at": "2017-06-01T19:01:36.573350Z", 831 | "data": { 832 | "created_at": "2017-02-01T15:55:42.058583Z", 833 | "subject_id": 7518, 834 | "subject_type": "vocabulary", 835 | "meaning_note": None, 836 | "reading_note": None, 837 | "meaning_synonyms": ["young girl"], 838 | "hidden": False, 839 | }, 840 | }, 841 | { 842 | "id": 1661853, 843 | "object": "study_material", 844 | "url": "https://api.wanikani.com/v2/study_materials/1661853", 845 | "data_updated_at": "2017-06-07T00:23:41.431508Z", 846 | "data": { 847 | "created_at": "2017-04-08T14:02:50.758641Z", 848 | "subject_id": 2798, 849 | "subject_type": "vocabulary", 850 | "meaning_note": None, 851 | "reading_note": None, 852 | "meaning_synonyms": ["balls"], 853 | "hidden": False, 854 | }, 855 | }, 856 | { 857 | "id": 1678472, 858 | "object": "study_material", 859 | "url": "https://api.wanikani.com/v2/study_materials/1678472", 860 | "data_updated_at": "2017-06-12T15:22:15.753065Z", 861 | "data": { 862 | "created_at": "2017-02-23T14:51:21.526934Z", 863 | "subject_id": 3416, 864 | "subject_type": "vocabulary", 865 | "meaning_note": None, 866 | "reading_note": None, 867 | "meaning_synonyms": ["wool"], 868 | "hidden": False, 869 | }, 870 | }, 871 | ], 872 | } 873 | 874 | SUMMARY = { 875 | "object": "report", 876 | "url": "https://api.wanikani.com/v2/summary", 877 | "data_updated_at": "2018-07-02T07:00:00.000000Z", 878 | "data": { 879 | "lessons": [{"available_at": "2018-07-02T07:00:00.000000Z", "subject_ids": []}], 880 | "next_reviews_at": "2018-07-02T09:00:00.000000Z", 881 | "reviews": [ 882 | {"available_at": "2018-07-02T07:00:00.000000Z", "subject_ids": [1, 2, 3]}, 883 | {"available_at": "2018-07-02T08:00:00.000000Z", "subject_ids": [4, 5, 6]}, 884 | {"available_at": "2018-07-02T09:00:00.000000Z", "subject_ids": [647]}, 885 | {"available_at": "2018-07-02T10:00:00.000000Z", "subject_ids": []}, 886 | {"available_at": "2018-07-02T11:00:00.000000Z", "subject_ids": []}, 887 | {"available_at": "2018-07-02T12:00:00.000000Z", "subject_ids": []}, 888 | { 889 | "available_at": "2018-07-02T13:00:00.000000Z", 890 | "subject_ids": [8800, 2944, 2943], 891 | }, 892 | {"available_at": "2018-07-02T14:00:00.000000Z", "subject_ids": []}, 893 | { 894 | "available_at": "2018-07-02T15:00:00.000000Z", 895 | "subject_ids": [ 896 | 658, 897 | 8738, 898 | 3447, 899 | 6237, 900 | 3449, 901 | 3451, 902 | 7676, 903 | 7528, 904 | 7621, 905 | 7679, 906 | 2822, 907 | 3420, 908 | 657, 909 | 5717, 910 | 3436, 911 | 7677, 912 | 7678, 913 | 3452, 914 | 7529, 915 | 3450, 916 | 3438, 917 | 7568, 918 | 7675, 919 | 3437, 920 | 3422, 921 | 3448, 922 | 4877, 923 | 7734, 924 | 7735, 925 | 666, 926 | 646, 927 | 648, 928 | ], 929 | }, 930 | {"available_at": "2018-07-02T16:00:00.000000Z", "subject_ids": []}, 931 | {"available_at": "2018-07-02T17:00:00.000000Z", "subject_ids": []}, 932 | {"available_at": "2018-07-02T18:00:00.000000Z", "subject_ids": []}, 933 | {"available_at": "2018-07-02T19:00:00.000000Z", "subject_ids": []}, 934 | {"available_at": "2018-07-02T20:00:00.000000Z", "subject_ids": []}, 935 | {"available_at": "2018-07-02T21:00:00.000000Z", "subject_ids": [2841]}, 936 | {"available_at": "2018-07-02T22:00:00.000000Z", "subject_ids": []}, 937 | { 938 | "available_at": "2018-07-02T23:00:00.000000Z", 939 | "subject_ids": [2945, 672, 2956, 2932, 2981, 2953, 674, 2936, 654], 940 | }, 941 | {"available_at": "2018-07-03T00:00:00.000000Z", "subject_ids": []}, 942 | {"available_at": "2018-07-03T01:00:00.000000Z", "subject_ids": []}, 943 | {"available_at": "2018-07-03T02:00:00.000000Z", "subject_ids": []}, 944 | {"available_at": "2018-07-03T03:00:00.000000Z", "subject_ids": []}, 945 | { 946 | "available_at": "2018-07-03T04:00:00.000000Z", 947 | "subject_ids": [ 948 | 671, 949 | 853, 950 | 3709, 951 | 2959, 952 | 4849, 953 | 2970, 954 | 2960, 955 | 2966, 956 | 2967, 957 | 2952, 958 | 2946, 959 | 8663, 960 | 2962, 961 | 2961, 962 | 2973, 963 | 2938, 964 | 2935, 965 | 2940, 966 | 7461, 967 | 2969, 968 | 2958, 969 | 2937, 970 | 7736, 971 | 2957, 972 | 8801, 973 | 2974, 974 | 677, 975 | 2939, 976 | 675, 977 | 663, 978 | 668, 979 | 650, 980 | 664, 981 | 670, 982 | 660, 983 | 676, 984 | ], 985 | }, 986 | {"available_at": "2018-07-03T05:00:00.000000Z", "subject_ids": []}, 987 | {"available_at": "2018-07-03T06:00:00.000000Z", "subject_ids": []}, 988 | {"available_at": "2018-07-03T07:00:00.000000Z", "subject_ids": []}, 989 | ], 990 | }, 991 | } 992 | REVIEWS_PAGE = { 993 | "object": "collection", 994 | "url": "https://api.wanikani.com/v2/reviews", 995 | "pages": { 996 | "per_page": 1000, 997 | "next_url": "https://api.wanikani.com/v2/reviews?page_after_id=168707639", 998 | "previous_url": None, 999 | }, 1000 | "total_count": 3, 1001 | "data_updated_at": "2018-07-06T19:30:19.657822Z", 1002 | "data": [ 1003 | { 1004 | "id": 6418820, 1005 | "object": "review", 1006 | "url": "https://api.wanikani.com/v2/reviews/6418820", 1007 | "data_updated_at": "2017-08-13T14:32:50.580980Z", 1008 | "data": { 1009 | "created_at": "2017-08-13T14:32:50.580980Z", 1010 | "assignment_id": 69392456, 1011 | "subject_id": 2514, 1012 | "starting_srs_stage": 8, 1013 | "ending_srs_stage": 9, 1014 | "incorrect_meaning_answers": 0, 1015 | "incorrect_reading_answers": 0, 1016 | }, 1017 | }, 1018 | { 1019 | "id": 6418839, 1020 | "object": "review", 1021 | "url": "https://api.wanikani.com/v2/reviews/6418839", 1022 | "data_updated_at": "2017-08-13T14:32:52.693772Z", 1023 | "data": { 1024 | "created_at": "2017-08-13T14:32:52.693772Z", 1025 | "assignment_id": 30950170, 1026 | "subject_id": 69, 1027 | "starting_srs_stage": 8, 1028 | "ending_srs_stage": 9, 1029 | "incorrect_meaning_answers": 0, 1030 | "incorrect_reading_answers": 0, 1031 | }, 1032 | }, 1033 | { 1034 | "id": 6418872, 1035 | "object": "review", 1036 | "url": "https://api.wanikani.com/v2/reviews/6418872", 1037 | "data_updated_at": "2017-08-13T14:32:56.587244Z", 1038 | "data": { 1039 | "created_at": "2017-08-13T14:32:56.587244Z", 1040 | "assignment_id": 30950168, 1041 | "subject_id": 60, 1042 | "starting_srs_stage": 8, 1043 | "ending_srs_stage": 9, 1044 | "incorrect_meaning_answers": 0, 1045 | "incorrect_reading_answers": 0, 1046 | }, 1047 | }, 1048 | ], 1049 | } 1050 | 1051 | LEVEL_PROGRESSIONS_PAGE = { 1052 | "object": "collection", 1053 | "url": "https://api.wanikani.com/v2/level_progressions", 1054 | "pages": {"per_page": 500, "next_url": None, "previous_url": None}, 1055 | "total_count": 2, 1056 | "data_updated_at": "2018-07-05T18:03:21.967992Z", 1057 | "data": [ 1058 | { 1059 | "id": 15446, 1060 | "object": "level_progression", 1061 | "url": "https://api.wanikani.com/v2/level_progressions/15446", 1062 | "data_updated_at": "2018-07-05T15:04:04.222661Z", 1063 | "data": { 1064 | "created_at": "2017-09-28T01:24:11.715238Z", 1065 | "level": 7, 1066 | "unlocked_at": "2017-06-12T15:24:48.181971Z", 1067 | "started_at": "2017-09-28T01:24:11.707880Z", 1068 | "passed_at": "2018-07-05T15:04:04.210181Z", 1069 | "completed_at": None, 1070 | "abandoned_at": None, 1071 | }, 1072 | }, 1073 | { 1074 | "id": 365549, 1075 | "object": "level_progression", 1076 | "url": "https://api.wanikani.com/v2/level_progressions/365549", 1077 | "data_updated_at": "2018-07-05T18:03:21.967992Z", 1078 | "data": { 1079 | "created_at": "2018-07-05T15:04:04.365184Z", 1080 | "level": 8, 1081 | "unlocked_at": "2018-07-05T15:04:04.338492Z", 1082 | "started_at": "2018-07-05T18:03:21.957917Z", 1083 | "passed_at": None, 1084 | "completed_at": None, 1085 | "abandoned_at": None, 1086 | }, 1087 | }, 1088 | ], 1089 | } 1090 | 1091 | RESETS_PAGE = { 1092 | "object": "collection", 1093 | "url": "https://api.wanikani.com/v2/resets", 1094 | "pages": {"per_page": 500, "next_url": None, "previous_url": None}, 1095 | "total_count": 1, 1096 | "data_updated_at": "2018-03-21T22:07:39.261116Z", 1097 | "data": [ 1098 | { 1099 | "id": 6529, 1100 | "object": "reset", 1101 | "url": "https://api.wanikani.com/v2/resets/6529", 1102 | "data_updated_at": "2018-03-21T22:07:39.261116Z", 1103 | "data": { 1104 | "created_at": "2018-03-21T22:04:13.313903Z", 1105 | "original_level": 13, 1106 | "target_level": 1, 1107 | "confirmed_at": "2018-03-21T22:05:44.454026Z", 1108 | }, 1109 | } 1110 | ], 1111 | } 1112 | --------------------------------------------------------------------------------