diff --git a/.coveragerc b/.coveragerc index 0a54067..afc7dc2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -10,3 +10,5 @@ exclude_lines = if __name__ == .__main__.: pragma: no cover if TYPE_CHECKING: + ... + pass diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..61d9081 --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 99 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..113e4b6 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,13 @@ + +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: lev145 +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 0000000..dd57ece --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,25 @@ +name: Python package + +on: + - push + - pull_request + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.8, 3.9, 3.10] + + steps: + - uses: actions/checkout@v1 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Test with tox + run: tox diff --git a/.gitignore b/.gitignore index ff09448..1cc7387 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,13 @@ + +Dockerfile + + +# Releases +releases + +# Pyinstaller +pyinstaller_builds + # VSCode files .vscode/ @@ -41,7 +51,6 @@ MANIFEST # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest -*.spec # Installer logs pip-log.txt @@ -114,9 +123,9 @@ celerybeat.pid # Environments .env .venv -env/ -venv/ -ENV/ +*env*/ +*venv*/ +*ENV*/ env.bak/ venv.bak/ diff --git a/Makefile b/Makefile index 99f7839..d1e0aa3 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,31 @@ +.PHONY: build +build: + pip install --editable . + + +.PHONY: binary +binary: + pyinstaller pyinstaller.spec --distpath pyinstaller_builds/linux_dist --workpath pyinstaller_builds/linux_build + + .PHONY: mkinit mkinit: mkinit sporepedia -w --black --nomods --relative --recursive + +.PHONY: run_tests +run_tests: + tox + + +.PHONY: coverage_status +coverage_status: + coverage run -m unittest discover tests "test_*" + coverage report -m + + +.PHONY: clear +clear: + rm -R build/ dist/ .eggs/ pyinstaller_builds/ + diff --git a/README.md b/README.md index b1057d6..bdbfbb8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,143 @@ # sporepedia.py Unofficial API client for sporepedia (https://www.spore.com/sporepedia) + + +# How to use +``` +> sporepedia --help +Usage: sporepedia [OPTIONS] COMMAND [ARGS]... + + CLI for sporepedia + +Options: + --help Show this message and exit. + +Commands: + search Search from sporepedia + +> sporepedia.exe search CAT --functions "is_civ_creature, is_adventure_creature, is_industry" --models "is_water" -F "most_popular_new" +{"result_size": 363, "results": [...]} +``` +A simple and intuitive script, convenient output data in the form of json:3 +(You can use a script from any programming language) + +# Build + +Build binary: +``` +pyinstaller pyinstaller.spec \ + --distpath pyinstaller_builds/dist \ + --workpath pyinstaller_builds/build +``` +Build for python (requires Python 3.7+) +``` +pip install --editable . +``` + +# Work in Python +### Install: +``` +pip install git+https://github.com/LEv145/sporepedia.py +``` + +Client: +```py +import asyncio + +from sporepedia import ( + SporepediaClient, + SearchParams, + FieldsSearchParam, + FunctionsSearchParam, + PurposesSearchParam, + SearchFilter, +) + + +async def main() -> None: + async with SporepediaClient() as client: + result = await client.search( + text="test", + lenght=20, + params=SearchParams( + fields=FieldsSearchParam( + is_name=True, + is_author=True, + is_tag=True, + ), + functions=FunctionsSearchParam( + is_tribe_creature=True, + is_adventure_creature=True, + is_industry=True, + is_adv_collect=True, + is_adv_puzzle=True, + is_adv_template=True + ), + purposes=PurposesSearchParam( + is_military=True, + is_cultural=True, + ), + ), + filter=SearchFilter.featured, + ) + print(result) # SearchServiceResult(result_size=48, results=[...]) + + +asyncio.run(main()) +``` +Low level API (More options to customize!>3): +```py +import asyncio + +from sporepedia import ( + APIClient, +) + + +async def main() -> None: + async with APIClient() as client: + result = await client.search( + text="test", + adv=1, + batch_id=2, + ) + print(result) # SearchServiceResult(result_size=48, results=[...]) + + +asyncio.run(main()) +``` + + +# Coverage +``` +Name Stmts Miss Branch BrPart Cover Missing +----------------------------------------------------------------------------------------------- +sporepedia/__init__.py 0 0 0 0 100% +sporepedia/__main__.py 0 0 0 0 100% +sporepedia/api/__init__.py 0 0 0 0 100% +sporepedia/api/client.py 0 0 0 0 100% +sporepedia/api/methods/__init__.py 0 0 0 0 100% +sporepedia/api/methods/dwr_parser.py 0 0 0 0 100% +sporepedia/api/methods/mixin_protocol.py 0 0 0 0 100% +sporepedia/api/methods/mixins/__init__.py 0 0 0 0 100% +sporepedia/api/methods/mixins/search/__init__.py 0 0 0 0 100% +sporepedia/api/methods/mixins/search/builders.py 0 0 0 0 100% +sporepedia/api/methods/mixins/search/composers.py 0 0 0 0 100% +sporepedia/api/methods/mixins/search/methods.py 0 0 0 0 100% +sporepedia/api/methods/mixins/search/models.py 0 0 0 0 100% +sporepedia/client.py 0 0 0 0 100% +tests/test_api.py 0 0 0 0 100% +tests/test_cli.py 0 0 0 0 100% +tests/test_client.py 0 0 0 0 100% +tests/test_dwr_parser.py 0 0 0 0 100% +tests/test_search.py 0 0 0 0 100% +----------------------------------------------------------------------------------------------- +TOTAL 0 0 0 0 100% +``` + +TODO: +- [ ] Autotest from git (Tox) +- [x] 100% tests +- [x] Cli client +- [ ] Docs +- [ ] New methods diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..acfefe2 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,6 @@ +[mypy] +ignore_missing_imports = True +strict_optional = True +implicit_reexport = True +warn_unused_ignores = False +disallow_any_generics = False diff --git a/poetry.lock b/poetry.lock index 9bf21a7..3a35d47 100644 --- a/poetry.lock +++ b/poetry.lock @@ -128,7 +128,10 @@ pathspec = ">=0.9.0,<1" platformdirs = ">=2" regex = ">=2021.4.4" tomli = ">=0.2.6,<2.0.0" -typing-extensions = ">=3.10.0.0" +typing-extensions = [ + {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}, + {version = "!=3.10.0.1", markers = "python_version >= \"3.10\""}, +] [package.extras] colorama = ["colorama (>=0.4.3)"] @@ -151,7 +154,7 @@ six = ">=1.9.0" [[package]] name = "charset-normalizer" -version = "2.0.8" +version = "2.0.9" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false @@ -164,7 +167,7 @@ unicode_backport = ["unicodedata2"] name = "click" version = "8.0.3" description = "Composable command line interface toolkit" -category = "main" +category = "dev" optional = false python-versions = ">=3.6" @@ -647,34 +650,6 @@ python-versions = ">=3.6" [package.extras] diagrams = ["jinja2", "railroad-diagrams"] -[[package]] -name = "pyqt5" -version = "5.15.6" -description = "Python bindings for the Qt cross platform application toolkit" -category = "main" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -PyQt5-Qt5 = ">=5.15.2" -PyQt5-sip = ">=12.8,<13" - -[[package]] -name = "pyqt5-qt5" -version = "5.15.2" -description = "The subset of a Qt installation needed by PyQt5." -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "pyqt5-sip" -version = "12.9.0" -description = "The sip module support for PyQt5" -category = "main" -optional = false -python-versions = ">=3.5" - [[package]] name = "python-dateutil" version = "2.8.2" @@ -706,28 +681,6 @@ category = "dev" optional = false python-versions = "*" -[[package]] -name = "quick" -version = "1.0" -description = "" -category = "main" -optional = false -python-versions = "*" -develop = false - -[package.dependencies] -click = ">=5.0" -PyQt5 = "*" - -[package.extras] -qtstyle = ["qdarkstyle"] - -[package.source] -type = "git" -url = "https://github.com/szsdk/quick" -reference = "master" -resolved_reference = "8a8421400686d7168c073058fdfc883b9b026b09" - [[package]] name = "regex" version = "2021.11.10" @@ -916,8 +869,8 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" -python-versions = ">=3.8,<3.10" -content-hash = "d622fcc6e5a5eb81ab46d0b2ede3212e3d88dc4ecda022fa3b19a3fe761f559b" +python-versions = ">=3.8,<3.11" +content-hash = "3f02addd6b282b8f0eb1c4af7fbf4107c0ee37601645168611966a67b69d8bee" [metadata.files] aiohttp = [ @@ -1047,8 +1000,8 @@ bson = [ {file = "bson-0.5.10.tar.gz", hash = "sha256:d6511b2ab051139a9123c184de1a04227262173ad593429d21e443d6462d6590"}, ] charset-normalizer = [ - {file = "charset-normalizer-2.0.8.tar.gz", hash = "sha256:735e240d9a8506778cd7a453d97e817e536bb1fc29f4f6961ce297b9c7a917b0"}, - {file = "charset_normalizer-2.0.8-py3-none-any.whl", hash = "sha256:83fcdeb225499d6344c8f7f34684c2981270beacc32ede2e669e94f7fa544405"}, + {file = "charset-normalizer-2.0.9.tar.gz", hash = "sha256:b0b883e8e874edfdece9c28f314e3dd5badf067342e42fb162203335ae61aa2c"}, + {file = "charset_normalizer-2.0.9-py3-none-any.whl", hash = "sha256:1eecaa09422db5be9e29d7fc65664e6c33bd06f9ced7838578ba40d58bdf3721"}, ] click = [ {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"}, @@ -1429,42 +1382,6 @@ pyparsing = [ {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"}, ] -pyqt5 = [ - {file = "PyQt5-5.15.6-cp36-abi3-macosx_10_13_x86_64.whl", hash = "sha256:33ced1c876f6a26e7899615a5a4efef2167c263488837c7beed023a64cebd051"}, - {file = "PyQt5-5.15.6-cp36-abi3-manylinux1_x86_64.whl", hash = "sha256:9d6efad0377aa78bf081a20ac752ce86096ded18f04c592d98f5b92dc879ad0a"}, - {file = "PyQt5-5.15.6-cp36-abi3-win32.whl", hash = "sha256:9d2dcdaf82263ae56023410a7af15d1fd746c8e361733a7d0d1bd1f004ec2793"}, - {file = "PyQt5-5.15.6-cp36-abi3-win_amd64.whl", hash = "sha256:f411ecda52e488e1d3c5cce7563e1b2ca9cf0b7531e3c25b03d9a7e56e07e7fc"}, - {file = "PyQt5-5.15.6.tar.gz", hash = "sha256:80343bcab95ffba619f2ed2467fd828ffeb0a251ad7225be5fc06dcc333af452"}, -] -pyqt5-qt5 = [ - {file = "PyQt5_Qt5-5.15.2-py3-none-macosx_10_13_intel.whl", hash = "sha256:76980cd3d7ae87e3c7a33bfebfaee84448fd650bad6840471d6cae199b56e154"}, - {file = "PyQt5_Qt5-5.15.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:1988f364ec8caf87a6ee5d5a3a5210d57539988bf8e84714c7d60972692e2f4a"}, - {file = "PyQt5_Qt5-5.15.2-py3-none-win32.whl", hash = "sha256:9cc7a768b1921f4b982ebc00a318ccb38578e44e45316c7a4a850e953e1dd327"}, - {file = "PyQt5_Qt5-5.15.2-py3-none-win_amd64.whl", hash = "sha256:750b78e4dba6bdf1607febedc08738e318ea09e9b10aea9ff0d73073f11f6962"}, -] -pyqt5-sip = [ - {file = "PyQt5_sip-12.9.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6d5bca2fc222d58e8093ee8a81a6e3437067bb22bc3f86d06ec8be721e15e90a"}, - {file = "PyQt5_sip-12.9.0-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:d59af63120d1475b2bf94fe8062610720a9be1e8940ea146c7f42bb449d49067"}, - {file = "PyQt5_sip-12.9.0-cp310-cp310-win32.whl", hash = "sha256:0fc9aefacf502696710b36cdc9fa2a61487f55ee883dbcf2c2a6477e261546f7"}, - {file = "PyQt5_sip-12.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:485972daff2fb0311013f471998f8ec8262ea381bded244f9d14edaad5f54271"}, - {file = "PyQt5_sip-12.9.0-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:d85002238b5180bce4b245c13d6face848faa1a7a9e5c6e292025004f2fd619a"}, - {file = "PyQt5_sip-12.9.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:83c3220b1ca36eb8623ba2eb3766637b19eb0ce9f42336ad8253656d32750c0a"}, - {file = "PyQt5_sip-12.9.0-cp36-cp36m-win32.whl", hash = "sha256:d8b2bdff7bbf45bc975c113a03b14fd669dc0c73e1327f02706666a7dd51a197"}, - {file = "PyQt5_sip-12.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:69a3ad4259172e2b1aa9060de211efac39ddd734a517b1924d9c6c0cc4f55f96"}, - {file = "PyQt5_sip-12.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:42274a501ab4806d2c31659170db14c282b8313d2255458064666d9e70d96206"}, - {file = "PyQt5_sip-12.9.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6a8701892a01a5a2a4720872361197cc80fdd5f49c8482d488ddf38c9c84f055"}, - {file = "PyQt5_sip-12.9.0-cp37-cp37m-win32.whl", hash = "sha256:ac57d796c78117eb39edd1d1d1aea90354651efac9d3590aac67fa4983f99f1f"}, - {file = "PyQt5_sip-12.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4347bd81d30c8e3181e553b3734f91658cfbdd8f1a19f254777f906870974e6d"}, - {file = "PyQt5_sip-12.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c446971c360a0a1030282a69375a08c78e8a61d568bfd6dab3dcc5cf8817f644"}, - {file = "PyQt5_sip-12.9.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:fc43f2d7c438517ee33e929e8ae77132749c15909afab6aeece5fcf4147ffdb5"}, - {file = "PyQt5_sip-12.9.0-cp38-cp38-win32.whl", hash = "sha256:055581c6fed44ba4302b70eeb82e979ff70400037358908f251cd85cbb3dbd93"}, - {file = "PyQt5_sip-12.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:c5216403d4d8d857ec4a61f631d3945e44fa248aa2415e9ee9369ab7c8a4d0c7"}, - {file = "PyQt5_sip-12.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a25b9843c7da6a1608f310879c38e6434331aab1dc2fe6cb65c14f1ecf33780e"}, - {file = "PyQt5_sip-12.9.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:dd05c768c2b55ffe56a9d49ce6cc77cdf3d53dbfad935258a9e347cbfd9a5850"}, - {file = "PyQt5_sip-12.9.0-cp39-cp39-win32.whl", hash = "sha256:4f8e05fe01d54275877c59018d8e82dcdd0bc5696053a8b830eecea3ce806121"}, - {file = "PyQt5_sip-12.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:b09f4cd36a4831229fb77c424d89635fa937d97765ec90685e2f257e56a2685a"}, - {file = "PyQt5_sip-12.9.0.tar.gz", hash = "sha256:d3e4489d7c2b0ece9d203ae66e573939f7f60d4d29e089c9f11daa17cfeaae32"}, -] python-dateutil = [ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, @@ -1477,7 +1394,6 @@ pywin32-ctypes = [ {file = "pywin32-ctypes-0.2.0.tar.gz", hash = "sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942"}, {file = "pywin32_ctypes-0.2.0-py2.py3-none-any.whl", hash = "sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98"}, ] -quick = [] regex = [ {file = "regex-2021.11.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9345b6f7ee578bad8e475129ed40123d265464c4cfead6c261fd60fc9de00bcf"}, {file = "regex-2021.11.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:416c5f1a188c91e3eb41e9c8787288e707f7d2ebe66e0a6563af280d9b68478f"}, diff --git a/pyinstaller.spec b/pyinstaller.spec new file mode 100644 index 0000000..4921a83 --- /dev/null +++ b/pyinstaller.spec @@ -0,0 +1,43 @@ +# -*- mode: python ; coding: utf-8 -*- + + +block_cipher = None + + +a = Analysis( + ["sporepedia/__main__.py"], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name="sporepedia", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/pyproject.toml b/pyproject.toml index ae5e340..5aa4f10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,22 +1,21 @@ [tool.poetry] name = "sporepedia.py" -version = "0.1.0" +version = "0.4" description = "Unofficial API client for sporepedia (https://www.spore.com/sporepedia)" authors = ["LEv145"] license = "MIT" packages = [ - { include="sporepedia", from="." }, + {include="sporepedia", from="."}, ] [tool.poetry.dependencies] -python = ">=3.8,<3.10" +python = ">=3.8,<3.11" aiohttp = "^3.8.1" dataclasses-json = "^0.5.6" asyncclick = "^8.0.1" anyio = "^3.4.0" Js2Py = "^0.71" -quick = {git = "https://github.com/szsdk/quick"} [tool.poetry.dev-dependencies] flake8 = "^4.0.1" diff --git a/sporepedia/__init__.py b/sporepedia/__init__.py index 5304051..f47ef78 100644 --- a/sporepedia/__init__.py +++ b/sporepedia/__init__.py @@ -1,2 +1,55 @@ -from .api import * -from .client import * +from .api import ( + ABCSearchParam, + APIClient, + APIClientProtocol, + AdventureStat, + Author, + Creation, + Difficulty, + DwrParserError, + FieldsSearchParam, + FunctionsSearchParam, + ModelsSearchParam, + PurposesSearchParam, + SearchFilter, + SearchMixin, + SearchParams, + SearchRequestComposer, + SearchResponceBuilder, + SearchServiceResult, + SporeDwrEngineParser, + Status, + StatusName, + parse_dwr, + to_python__mockup, +) +from .client import ( + SporepediaClient, +) + +__all__ = [ + "ABCSearchParam", + "APIClient", + "APIClientProtocol", + "AdventureStat", + "Author", + "Creation", + "Difficulty", + "DwrParserError", + "FieldsSearchParam", + "FunctionsSearchParam", + "ModelsSearchParam", + "PurposesSearchParam", + "SearchFilter", + "SearchMixin", + "SearchParams", + "SearchRequestComposer", + "SearchResponceBuilder", + "SearchServiceResult", + "SporeDwrEngineParser", + "SporepediaClient", + "Status", + "StatusName", + "parse_dwr", + "to_python__mockup", +] diff --git a/sporepedia/__main__.py b/sporepedia/__main__.py index d173445..ce447a2 100644 --- a/sporepedia/__main__.py +++ b/sporepedia/__main__.py @@ -1,10 +1,9 @@ #!/usr/bin/env python from dataclasses import is_dataclass -from typing import TYPE_CHECKING, Dict, Optional, Type +from typing import TYPE_CHECKING, Any, Dict, Optional, Type import asyncclick as click - from sporepedia import ( SporepediaClient, SearchParams, @@ -21,13 +20,15 @@ from sporepedia import ABCSearchParam - from click import Context, Parameter + from asyncclick import Context, Parameter _client = SporepediaClient() -class BoolDatetimeType(click.ParamType): +class BoolDataclassType(click.ParamType): + name = "bool_dataclass" + def __init__(self, dataclass_: Type["ABCSearchParam"]): if not is_dataclass(dataclass_): # TODO?: Dataclass type? raise ValueError(f"{dataclass_!r} is not dataclass") @@ -38,19 +39,19 @@ def convert( user_input: str, parameter: Optional["Parameter"], ctx: Optional["Context"] - ): + ) -> Any: if user_input == "all": return self._dataclass.all() if user_input == "none": return self._dataclass.none() - dataclass_fields: Dict[str, Field] = ( + dataclass_fields: Dict[str, "Field"] = ( self._dataclass.__dataclass_fields__ # type: ignore ) user_values = [ - item + item.lower() for i in user_input.split(",") if (item := i.strip()) ] @@ -59,13 +60,78 @@ def convert( for value in user_values: if value not in dataclass_fields: - self.fail(f"{value!r} not in parametes [{', '.join(dataclass_fields.keys())}]") + self.fail(f"{value!r} not in {self._get_metavar()}") else: dataclass_attrs[value] = True return self._dataclass(**dataclass_attrs) # type: ignore + def get_metavar(self, parameter: "Parameter") -> str: + return self._get_metavar() + + def _get_metavar(self) -> str: # TODO?: Rename + dataclass_fields: Dict[str, "Field"] = ( + self._dataclass.__dataclass_fields__ # type: ignore + ) + + return f"[{'|'.join(dataclass_fields)}]" + @click.group() -async def cli(): - """CLI for Spore REST API""" +async def cli() -> None: + """CLI for sporepedia""" + + +@cli.command(help="Search from sporepedia") +@click.argument( + "text", + type=str, +) +@click.option( + "-Fu", "--functions", + type=BoolDataclassType(FunctionsSearchParam), +) +@click.option( + "-Fi", "--fields", + type=BoolDataclassType(FieldsSearchParam), +) +@click.option( + "-M", "--models", + type=BoolDataclassType(ModelsSearchParam), +) +@click.option( + "-P", "--purposes", + type=BoolDataclassType(PurposesSearchParam), +) +@click.option( + "-F", "--filter", "filter_", + type=click.Choice(SearchFilter._member_names_, case_sensitive=False), +) +async def search( + text: str, + functions: Optional[FunctionsSearchParam], + fields: Optional[FieldsSearchParam], + models: Optional[ModelsSearchParam], + purposes: Optional[PurposesSearchParam], + filter_: Optional[str], +) -> None: + async with _client as client: + result = await client.search( + text, + params=SearchParams( + functions=functions, + fields=fields, + models=models, + purposes=purposes, + ), + filter=( + SearchFilter[filter_] + if filter_ is not None else + None + ) + ) + click.echo(result.to_json()) + + +if __name__ == "__main__": + cli() diff --git a/sporepedia/api/__init__.py b/sporepedia/api/__init__.py index 9a79170..b569136 100644 --- a/sporepedia/api/__init__.py +++ b/sporepedia/api/__init__.py @@ -1,3 +1,53 @@ -from .client import * -from .dwr_parser import * -from .methods import * +from .client import ( + APIClient, +) +from .methods import ( + ABCSearchParam, + APIClientProtocol, + AdventureStat, + Author, + Creation, + Difficulty, + DwrParserError, + FieldsSearchParam, + FunctionsSearchParam, + ModelsSearchParam, + PurposesSearchParam, + SearchFilter, + SearchMixin, + SearchParams, + SearchRequestComposer, + SearchResponceBuilder, + SearchServiceResult, + SporeDwrEngineParser, + Status, + StatusName, + parse_dwr, + to_python__mockup, +) + +__all__ = [ + "ABCSearchParam", + "APIClient", + "APIClientProtocol", + "AdventureStat", + "Author", + "Creation", + "Difficulty", + "DwrParserError", + "FieldsSearchParam", + "FunctionsSearchParam", + "ModelsSearchParam", + "PurposesSearchParam", + "SearchFilter", + "SearchMixin", + "SearchParams", + "SearchRequestComposer", + "SearchResponceBuilder", + "SearchServiceResult", + "SporeDwrEngineParser", + "Status", + "StatusName", + "parse_dwr", + "to_python__mockup", +] diff --git a/sporepedia/api/client.py b/sporepedia/api/client.py index 20d533d..87dc2fa 100644 --- a/sporepedia/api/client.py +++ b/sporepedia/api/client.py @@ -1,4 +1,4 @@ -from typing import Optional, Type, TypeVar +from typing import Any, Optional, Type, TypeVar from types import TracebackType import aiohttp @@ -17,11 +17,11 @@ class APIClient(SearchMixin): def __init__(self) -> None: self._session: Optional[aiohttp.ClientSession] = None - async def request( # TODO?: New name, strange + async def request( self, method: str, url: str, - *args, **kw, + **kwargs: Any, ) -> aiohttp.ClientResponse: if self._session is None: raise ValueError("Session is not exist") @@ -29,7 +29,7 @@ async def request( # TODO?: New name, strange response = await self._session.request( url=url, method=method, - *args, **kw, + **kwargs, ) response.raise_for_status() return response diff --git a/sporepedia/api/dwr_parser.py b/sporepedia/api/dwr_parser.py deleted file mode 100644 index 6347f3e..0000000 --- a/sporepedia/api/dwr_parser.py +++ /dev/null @@ -1,49 +0,0 @@ -from js2py.base import JsObjectWrapper -from js2py import EvalJs - - -def parse_dwr(raw_data: str): - parser = SporeDwrEngineParser() - return parser.parse(raw_data) - - -class SporeDwrEngineParser(): - _extension_code = ( - "outlog = null;\n" - "errorlog = null;\n" - - "dwr = {};\n" - "dwr.engine = {};\n" - - "dwr.engine._remoteHandleBatchException = function(exeption, batchId) {\n" - " errorlog = exeption;\n" - "};\n" - - "dwr.engine._remoteHandleCallback = function(batchId, callId, reply) {\n" - " outlog = reply;\n" - "};\n" - ) - - def parse(self, raw_data: str) -> "JsObjectWrapper": - js_code = raw_data.replace("throw 'allowScriptTagRemoting is false.';", "") # Remove throw - - context = EvalJs() - context.execute( - ( - f"{self._extension_code}" - f"{js_code}" - ) - ) - - outlog, errorlog = context.outlog, context.errorlog - - if errorlog is not None: - raise DwrParserError(message=errorlog["message"], name=errorlog["name"]) - - return outlog - - -class DwrParserError(Exception): - def __init__(self, message: str, name: str) -> None: - self.message = message - self.name = name diff --git a/sporepedia/api/methods/__init__.py b/sporepedia/api/methods/__init__.py index b38026c..ea30026 100644 --- a/sporepedia/api/methods/__init__.py +++ b/sporepedia/api/methods/__init__.py @@ -1,10 +1,53 @@ -from .search import ( - SearchMixin, - SearchFilter, - SearchParams, +from .dwr_parser import ( + DwrParserError, + SporeDwrEngineParser, + parse_dwr, + to_python__mockup, +) +from .mixin_protocol import ( + APIClientProtocol, +) +from .mixins import ( ABCSearchParam, - FunctionsSearchParam, + AdventureStat, + Author, + Creation, + Difficulty, FieldsSearchParam, + FunctionsSearchParam, ModelsSearchParam, PurposesSearchParam, + SearchFilter, + SearchMixin, + SearchParams, + SearchRequestComposer, + SearchResponceBuilder, + SearchServiceResult, + Status, + StatusName, ) + +__all__ = [ + "ABCSearchParam", + "APIClientProtocol", + "AdventureStat", + "Author", + "Creation", + "Difficulty", + "DwrParserError", + "FieldsSearchParam", + "FunctionsSearchParam", + "ModelsSearchParam", + "PurposesSearchParam", + "SearchFilter", + "SearchMixin", + "SearchParams", + "SearchRequestComposer", + "SearchResponceBuilder", + "SearchServiceResult", + "SporeDwrEngineParser", + "Status", + "StatusName", + "parse_dwr", + "to_python__mockup", +] diff --git a/sporepedia/api/methods/dwr_parser.py b/sporepedia/api/methods/dwr_parser.py new file mode 100644 index 0000000..d4ff5ca --- /dev/null +++ b/sporepedia/api/methods/dwr_parser.py @@ -0,0 +1,105 @@ +from typing import Any +from unittest.mock import patch + +import js2py +from js2py import EvalJs +from js2py.base import ( + to_list, + to_dict, + Scope, + PyJs, + PyJsUndefined, + PyJsNull, + PyJsNumber, + PyJsString, + PyJsBoolean, + PyObjectWrapper, + PyJsArray, + PyJsObject, + JsObjectWrapper, +) +from js2py.constructors.jsdate import PyJsDate + + +def parse_dwr(raw_data: str) -> JsObjectWrapper: + parser = SporeDwrEngineParser() + return parser.parse(raw_data) + + +class SporeDwrEngineParser(): + _extension_code = ( + "outlog = null;\n" + "errorlog = null;\n" + + "dwr = {};\n" + "dwr.engine = {};\n" + + "dwr.engine._remoteHandleBatchException = function(exeption, batchId) {\n" + " errorlog = exeption;\n" + "};\n" + + "dwr.engine._remoteHandleCallback = function(batchId, callId, reply) {\n" + " outlog = reply;\n" + "};\n" + ) + + def parse(self, raw_data: str) -> "JsObjectWrapper": + js_code = raw_data.replace("throw 'allowScriptTagRemoting is false.';", "") # Remove throw + + context = EvalJs() + context.execute( + ( + f"{self._extension_code}" + f"{js_code}" + ) + ) + + patcher = patch.object( + js2py.base, + "to_python", + to_python__mockup, + ) + patcher.start() + + outlog, errorlog = context.outlog, context.errorlog + + if errorlog is not None: + raise DwrParserError(message=errorlog["message"], name=errorlog["name"]) + + return outlog + + +class DwrParserError(Exception): + def __init__(self, message: str, name: str) -> None: + super().__init__() + self.message = message + self.name = name + + +def to_python__mockup(val: Any) -> Any: + if not isinstance(val, PyJs): # pragma: no cover + return val + if isinstance(val, PyJsUndefined) or isinstance(val, PyJsNull): # pragma: no cover + return None + elif isinstance(val, PyJsNumber): # pragma: no cover + # this can be either float or long/int better to assume its int/long when a whole number... + v = val.value + try: + i = int(v) if v == v else v # type: ignore + return v if i != v else i + except Exception: + return v + elif isinstance(val, (PyJsString, PyJsBoolean)): # pragma: no cover + return val.value + elif isinstance(val, PyObjectWrapper): # pragma: no cover + return val.__dict__['obj'] + elif isinstance(val, PyJsArray) and val.CONVERT_TO_PY_PRIMITIVES: # pragma: no cover + return to_list(val) + elif isinstance(val, PyJsObject) and val.CONVERT_TO_PY_PRIMITIVES: # pragma: no cover + return to_dict(val) + elif isinstance(val, PyJsDate): + return val.to_utc_dt() + elif isinstance(val, (PyJsObject, PyJsArray, Scope)): + return JsObjectWrapper(val) + else: # pragma: no cover + raise Exception(f"{type(val)} is not supported to python convert") diff --git a/sporepedia/api/mixin_protocol.py b/sporepedia/api/methods/mixin_protocol.py similarity index 69% rename from sporepedia/api/mixin_protocol.py rename to sporepedia/api/methods/mixin_protocol.py index fdbf9c1..7c83377 100644 --- a/sporepedia/api/mixin_protocol.py +++ b/sporepedia/api/methods/mixin_protocol.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Protocol +from typing import Any, Protocol from aiohttp import ClientResponse @@ -8,8 +8,9 @@ async def request( self, method: str, url: str, - *args, **kw, + **kwargs: Any, ) -> ClientResponse: + """Protocol for api client""" ... diff --git a/sporepedia/api/methods/mixins/__init__.py b/sporepedia/api/methods/mixins/__init__.py new file mode 100644 index 0000000..069f9da --- /dev/null +++ b/sporepedia/api/methods/mixins/__init__.py @@ -0,0 +1,39 @@ +from .search import ( + ABCSearchParam, + AdventureStat, + Author, + Creation, + Difficulty, + FieldsSearchParam, + FunctionsSearchParam, + ModelsSearchParam, + PurposesSearchParam, + SearchFilter, + SearchMixin, + SearchParams, + SearchRequestComposer, + SearchResponceBuilder, + SearchServiceResult, + Status, + StatusName, +) + +__all__ = [ + "ABCSearchParam", + "AdventureStat", + "Author", + "Creation", + "Difficulty", + "FieldsSearchParam", + "FunctionsSearchParam", + "ModelsSearchParam", + "PurposesSearchParam", + "SearchFilter", + "SearchMixin", + "SearchParams", + "SearchRequestComposer", + "SearchResponceBuilder", + "SearchServiceResult", + "Status", + "StatusName", +] diff --git a/sporepedia/api/methods/mixins/search/__init__.py b/sporepedia/api/methods/mixins/search/__init__.py new file mode 100644 index 0000000..a6bf86d --- /dev/null +++ b/sporepedia/api/methods/mixins/search/__init__.py @@ -0,0 +1,45 @@ +from .builders import ( + SearchResponceBuilder, +) +from .composers import ( + SearchRequestComposer, +) +from .methods import ( + ABCSearchParam, + FieldsSearchParam, + FunctionsSearchParam, + ModelsSearchParam, + PurposesSearchParam, + SearchFilter, + SearchMixin, + SearchParams, +) +from .models import ( + AdventureStat, + Author, + Creation, + Difficulty, + SearchServiceResult, + Status, + StatusName, +) + +__all__ = [ + "ABCSearchParam", + "AdventureStat", + "Author", + "Creation", + "Difficulty", + "FieldsSearchParam", + "FunctionsSearchParam", + "ModelsSearchParam", + "PurposesSearchParam", + "SearchFilter", + "SearchMixin", + "SearchParams", + "SearchRequestComposer", + "SearchResponceBuilder", + "SearchServiceResult", + "Status", + "StatusName", +] diff --git a/sporepedia/api/methods/search/builders.py b/sporepedia/api/methods/mixins/search/builders.py similarity index 85% rename from sporepedia/api/methods/search/builders.py rename to sporepedia/api/methods/mixins/search/builders.py index 97b954c..9283d0a 100644 --- a/sporepedia/api/methods/search/builders.py +++ b/sporepedia/api/methods/mixins/search/builders.py @@ -1,9 +1,6 @@ -from typing import cast -from unittest.mock import patch +from typing import Any, Dict, List, cast -import js2py.base - -from sporepedia.api.dwr_parser import parse_dwr +from ...dwr_parser import parse_dwr from .models import ( SearchServiceResult, Creation, @@ -13,22 +10,16 @@ StatusName, Difficulty, ) -from .mockups import to_python__mockup class SearchResponceBuilder(): def build(self, raw_data: str) -> SearchServiceResult: js_object = parse_dwr(raw_data) - with patch.object( - js2py.base, - "to_python", - to_python__mockup, - ): - data = js_object.to_dict() + data = js_object.to_dict() result_size = cast(int, data["resultSize"]) - raw_results = cast(list, data["results"]) + raw_results = cast(List[Dict[str, Any]], data["results"]) return SearchServiceResult( result_size=result_size, @@ -38,7 +29,7 @@ def build(self, raw_data: str) -> SearchServiceResult: ] ) - def build_creation(self, raw_data: dict) -> Creation: # TODO?: Use cast for typing + def build_creation(self, raw_data: Dict[str, Any]) -> Creation: return Creation( id=raw_data["id"], original_id=raw_data["originalId"], @@ -77,7 +68,7 @@ def build_creation(self, raw_data: dict) -> Creation: # TODO?: Use cast for typ status=self.build_status(raw_data["status"]), ) - def build_author(self, raw_data: dict) -> Author: + def build_author(self, raw_data: Dict[str, Any]) -> Author: return Author( id=raw_data["id"], user_id=raw_data["userId"], @@ -105,7 +96,7 @@ def build_author(self, raw_data: dict) -> Author: newest_asset_create_at=raw_data["newestAssetCreated"], ) - def build_adventure_stat(self, raw_data: dict) -> AdventureStat: + def build_adventure_stat(self, raw_data: Dict[str, Any]) -> AdventureStat: return AdventureStat( id=raw_data["adventureId"], leaderboard_id=raw_data["adventureLeaderboardId"], @@ -118,7 +109,7 @@ def build_adventure_stat(self, raw_data: dict) -> AdventureStat: update_at=raw_data["updated"], ) - def build_status(self, raw_data: dict) -> Status: + def build_status(self, raw_data: Dict[str, Any]) -> Status: return Status( name=StatusName(raw_data["name"]), name_key=raw_data["nameKey"], diff --git a/sporepedia/api/methods/search/composers.py b/sporepedia/api/methods/mixins/search/composers.py similarity index 82% rename from sporepedia/api/methods/search/composers.py rename to sporepedia/api/methods/mixins/search/composers.py index 7da821b..d921884 100644 --- a/sporepedia/api/methods/search/composers.py +++ b/sporepedia/api/methods/mixins/search/composers.py @@ -35,10 +35,10 @@ def compose( f"c0-searchText={text}\n" f"c0-maxResults={lenght}\n" f"c0-filter={_filter}\n" - f"c0-fields={params._fields.compose_string()}\n" - f"c0-functions={params._functions.compose_string()}\n" - f"c0-purposes={params._purposes.compose_string()}\n" - f"c0-modes={params._models.compose_string()}\n" + f"c0-fields={params.guaranteed_fields.compose_string()}\n" + f"c0-functions={params.guaranteed_functions.compose_string()}\n" + f"c0-purposes={params.guaranteed_purposes.compose_string()}\n" + f"c0-modes={params.guaranteed_models.compose_string()}\n" "c0-param0=Object_Object:{" "adv:reference:c0-adv, " # noqa: E131 "searchText:reference:c0-searchText, " diff --git a/sporepedia/api/methods/search/methods.py b/sporepedia/api/methods/mixins/search/methods.py similarity index 89% rename from sporepedia/api/methods/search/methods.py rename to sporepedia/api/methods/mixins/search/methods.py index 3805028..54c95c9 100644 --- a/sporepedia/api/methods/search/methods.py +++ b/sporepedia/api/methods/mixins/search/methods.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, Dict, Optional +from typing import TYPE_CHECKING, Optional, TypeVar, Type from abc import ABC, abstractmethod -from dataclasses import Field, dataclass, fields +from dataclasses import dataclass, fields from enum import Enum from .composers import SearchRequestComposer @@ -9,7 +9,7 @@ if TYPE_CHECKING: - from sporepedia.api.mixin_protocol import APIClientProtocol + from ...mixin_protocol import APIClientProtocol class SearchMixin(): @@ -47,15 +47,17 @@ async def search( class ABCSearchParam(ABC): + SearchParamType = TypeVar("SearchParamType", bound="ABCSearchParam") + @classmethod - def all(cls): + def all(cls: Type[SearchParamType]) -> SearchParamType: return cls(**{ field.name: True for field in fields(cls) }) # type: ignore @classmethod - def none(cls): + def none(cls: Type[SearchParamType]) -> SearchParamType: return cls(**{ field.name: False for field in fields(cls) @@ -80,7 +82,7 @@ class FieldsSearchParam(ABCSearchParam): is_tag: bool = False is_description: bool = False - def __post_init__(self): + def __post_init__(self) -> None: convert_data = ( (self.is_name, "name"), (self.is_author, "author"), @@ -120,7 +122,7 @@ class FunctionsSearchParam(ABCSearchParam): is_adv_story: bool = False is_adv_template: bool = False - def __post_init__(self): + def __post_init__(self) -> None: convert_data = ( (self.is_creature, "CREATURE"), (self.is_tribe_creature, "TRIBE_CREATURE"), @@ -160,7 +162,7 @@ class ModelsSearchParam(ABCSearchParam): is_air: bool = False is_water: bool = False - def __post_init__(self): + def __post_init__(self) -> None: convert_data = ( (self.is_land, "LAND"), (self.is_air, "AIR"), @@ -183,7 +185,7 @@ class PurposesSearchParam(ABCSearchParam): is_cultural: bool = False is_colony: bool = False - def __post_init__(self): + def __post_init__(self) -> None: convert_data = ( (self.is_military, "MILITARY"), (self.is_economic, "ECONOMIC"), @@ -207,23 +209,24 @@ class SearchParams(): models: Optional["ModelsSearchParam"] = None purposes: Optional["PurposesSearchParam"] = None - def __post_init__(self): - self._fields = ( + def __post_init__(self) -> None: + # TODO?: Better name + self.guaranteed_fields = ( self.fields if self.fields is not None else FieldsSearchParam() ) - self._functions = ( + self.guaranteed_functions = ( self.functions if self.functions is not None else FunctionsSearchParam() ) - self._models = ( + self.guaranteed_models = ( self.models if self.models is not None else ModelsSearchParam() ) - self._purposes = ( + self.guaranteed_purposes = ( self.purposes if self.purposes is not None else PurposesSearchParam() diff --git a/sporepedia/api/methods/search/models.py b/sporepedia/api/methods/mixins/search/models.py similarity index 88% rename from sporepedia/api/methods/search/models.py rename to sporepedia/api/methods/mixins/search/models.py index 7ce2e2a..8f49037 100644 --- a/sporepedia/api/methods/search/models.py +++ b/sporepedia/api/methods/mixins/search/models.py @@ -3,6 +3,8 @@ from datetime import datetime from dataclasses import dataclass +from dataclasses_json import DataClassJsonMixin + class StatusName(str, Enum): classified = "CLASSIFIED" @@ -20,14 +22,14 @@ class Difficulty(int, Enum): @dataclass -class Status(): +class Status(DataClassJsonMixin): name: StatusName name_key: str declaring_class_name: str @dataclass -class Author(): +class Author(DataClassJsonMixin): id: int user_id: int nucleus_user_id: int @@ -51,7 +53,7 @@ class Author(): @dataclass -class AdventureStat(): +class AdventureStat(DataClassJsonMixin): id: int leaderboard_id: int @@ -67,7 +69,7 @@ class AdventureStat(): @dataclass -class Creation(): +class Creation(DataClassJsonMixin): id: int original_id: Optional[int] parent_id: Optional[int] @@ -100,6 +102,6 @@ class Creation(): @dataclass -class SearchServiceResult(): +class SearchServiceResult(DataClassJsonMixin): result_size: int results: List[Creation] diff --git a/sporepedia/api/methods/search/__init__.py b/sporepedia/api/methods/search/__init__.py deleted file mode 100644 index 67de22b..0000000 --- a/sporepedia/api/methods/search/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .methods import * -from .builders import * -from .composers import * -from .mockups import * -from .models import * diff --git a/sporepedia/api/methods/search/mockups.py b/sporepedia/api/methods/search/mockups.py deleted file mode 100644 index 239d658..0000000 --- a/sporepedia/api/methods/search/mockups.py +++ /dev/null @@ -1,45 +0,0 @@ -from js2py.base import ( - to_list, - to_dict, - Scope, - PyJs, - PyJsUndefined, - PyJsNull, - PyJsNumber, - PyJsString, - PyJsBoolean, - PyObjectWrapper, - PyJsArray, - PyJsObject, - JsObjectWrapper, -) -from js2py.constructors.jsdate import PyJsDate - - -def to_python__mockup(val): - if not isinstance(val, PyJs): # pragma: no cover - return val - if isinstance(val, PyJsUndefined) or isinstance(val, PyJsNull): # pragma: no cover - return None - elif isinstance(val, PyJsNumber): # pragma: no cover - # this can be either float or long/int better to assume its int/long when a whole number... - v = val.value - try: - i = int(v) if v == v else v # type: ignore - return v if i != v else i - except Exception: - return v - elif isinstance(val, (PyJsString, PyJsBoolean)): # pragma: no cover - return val.value - elif isinstance(val, PyObjectWrapper): # pragma: no cover - return val.__dict__['obj'] - elif isinstance(val, PyJsArray) and val.CONVERT_TO_PY_PRIMITIVES: # pragma: no cover - return to_list(val) - elif isinstance(val, PyJsObject) and val.CONVERT_TO_PY_PRIMITIVES: # pragma: no cover - return to_dict(val) - elif isinstance(val, PyJsDate): - return val.to_utc_dt() - elif isinstance(val, (PyJsObject, PyJsArray, Scope)): - return JsObjectWrapper(val) - else: # pragma: no cover - raise Exception(f"{type(val)} is not supported to python convert") diff --git a/sporepedia/client.py b/sporepedia/client.py index cfc9079..ef1c3ce 100644 --- a/sporepedia/client.py +++ b/sporepedia/client.py @@ -10,7 +10,7 @@ from aiohttp import ClientSession - from api.methods.search import ( + from .api import ( SearchFilter, SearchServiceResult, SearchParams, @@ -20,7 +20,7 @@ class SporepediaClient(): SporepediaClientType = TypeVar("SporepediaClientType", bound="SporepediaClient") - def __init__(self): + def __init__(self) -> None: self._api = APIClient() async def search( @@ -35,7 +35,7 @@ async def search( text=text, lenght=lenght, params=params, - filter=filter + filter=filter, ) return result diff --git a/tests/test_cli.py b/tests/test_cli.py index 6656c52..c2b0905 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,21 +1,30 @@ import unittest -from unittest.mock import patch, Mock +from unittest.mock import Mock, patch from asyncclick import BadParameter +from asyncclick.testing import CliRunner +import sporepedia.__main__ +from sporepedia.__main__ import ( + BoolDataclassType, + cli, +) from sporepedia import FunctionsSearchParam -from sporepedia.__main__ import BoolDatetimeType -class BoolDatetimeTypeTest(unittest.TestCase): +class BoolDataclassTypeTest(unittest.TestCase): + def test__no_dataclass_error(self): + with self.assertRaises(ValueError): + BoolDataclassType(None) # type: ignore + def test__no_in_paramets_error(self): - converter = BoolDatetimeType(FunctionsSearchParam) + converter = BoolDataclassType(FunctionsSearchParam) with self.assertRaises(BadParameter): converter.convert("123", parameter=None, ctx=None) def test__string_split(self): - converter = BoolDatetimeType(FunctionsSearchParam) + converter = BoolDataclassType(FunctionsSearchParam) result = converter.convert( "is_city_hall,is_house,is_adv_unset,is_adv_template,", @@ -57,15 +66,60 @@ def test__string_split(self): ) def test__all_string(self): - converter = BoolDatetimeType(FunctionsSearchParam) + converter = BoolDataclassType(FunctionsSearchParam) result = converter.convert("all", parameter=None, ctx=None) self.assertEqual(result, FunctionsSearchParam.all()) def test__none_string(self): - converter = BoolDatetimeType(FunctionsSearchParam) + converter = BoolDataclassType(FunctionsSearchParam) result = converter.convert("none", parameter=None, ctx=None) self.assertEqual(result, FunctionsSearchParam.none()) + + def test__get_metavar(self): + converter = BoolDataclassType(FunctionsSearchParam) + + parameter = Mock() + result = converter.get_metavar(parameter) + + self.assertEqual( + result, + ( + "[is_creature|is_tribe_creature|is_civ_creature" + "|is_space_creature|is_adventure_creature|is_city_hall" + "|is_house|is_industry|is_entertainment|is_ufo" + "|is_adv_attack|is_adv_collect|is_adv_defend" + "|is_adv_explore|is_adv_unset|is_adv_puzzle|" + "is_adv_quest|is_adv_socialize|is_adv_story|is_adv_template]" + ) + ) + + +class TestCommands(unittest.IsolatedAsyncioTestCase): + async def test__search(self): + runner = CliRunner() + + with patch.object(sporepedia.__main__.SporepediaClient, "search") as mock: + mock.return_value.to_json = Mock( + return_value='{"resultSize": 2668, "results": [], "resultsPerType": {}}' + ) + result = await runner.invoke( + cli, + ( + "search", + "test", + "-Fu", "IS_CREATURE, is_civ_creature", + "-F", "most_popular_new", + "--fields", "is_author,is_description", + "-P", "is_colony", + ) + ) + + self.assertIsNone(result.exception) + self.assertEqual( + result.output, + '{"resultSize": 2668, "results": [], "resultsPerType": {}}\n' + ) diff --git a/tests/test_client.py b/tests/test_client.py index 0e72bbf..7df8438 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -9,12 +9,11 @@ class SporepediaClientTest(unittest.IsolatedAsyncioTestCase): @patch.object(APIClient, "request") async def test__search(self, mock_request: AsyncMock): - client = SporepediaClient() - - with open(Path("./tests/testdata/dwr_search_testdata.js")) as fp: - mock_request.return_value.text.return_value = fp.read() + async with SporepediaClient() as client: + with open(Path("./tests/testdata/dwr_search_testdata.js")) as fp: + mock_request.return_value.text.return_value = fp.read() - await client.search(text="Spore") + await client.search(text="Spore") async def test__close_exception(self): with self.assertRaises(ValueError): diff --git a/tests/test_dwr_parser.py b/tests/test_dwr_parser.py index e4ea4e3..17ef058 100644 --- a/tests/test_dwr_parser.py +++ b/tests/test_dwr_parser.py @@ -1,11 +1,13 @@ +import json from typing import cast import unittest from pathlib import Path -from sporepedia.api.dwr_parser import ( +from sporepedia.api.methods.dwr_parser import ( SporeDwrEngineParser, DwrParserError, ) +from tests.utils.json import json_datetime_hook, json_serial class SporeDwrEngineParsersTest(unittest.TestCase): @@ -29,9 +31,9 @@ def test__normal(self): with open(Path("./tests/testdata/dwr_search_testdata.js")) as fp: js_code = fp.read() + with open(Path("./tests/testdata/dwr_search_testdata.json")) as fp: + result = json.load(fp, object_hook=json_datetime_hook) + outlog = parser.parse(js_code) - self.assertEqual( # TODO: How test JsObjectWrapper - outlog.to_dict()["resultSize"], - 1 - ) + self.assertEqual(outlog.to_dict(), result) diff --git a/tests/test_search.py b/tests/test_search.py index 15a8834..1f0392d 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -8,7 +8,7 @@ from tests.utils.json import json_datetime_hook from sporepedia.api.client import APIClient -from sporepedia.api.methods.search import ( +from sporepedia.api.methods.mixins.search import ( SearchResponceBuilder, SearchRequestComposer, SearchParams, @@ -39,10 +39,10 @@ def test__all(self): ) dataclasses = ( - params._fields, - params._functions, - params._models, - params._purposes, + params.guaranteed_fields, + params.guaranteed_functions, + params.guaranteed_models, + params.guaranteed_purposes, ) for dataclass_ in dataclasses: @@ -58,10 +58,10 @@ def test__none(self): ) dataclasses = ( - params._fields, - params._functions, - params._models, - params._purposes, + params.guaranteed_fields, + params.guaranteed_functions, + params.guaranteed_models, + params.guaranteed_purposes, ) for dataclass_ in dataclasses: diff --git a/tests/testdata/dwr_search_testdata.json b/tests/testdata/dwr_search_testdata.json new file mode 100644 index 0000000..2176a17 --- /dev/null +++ b/tests/testdata/dwr_search_testdata.json @@ -0,0 +1,133 @@ +{ + "resultSize": 1, + "results": [ + { + "adventureStat": { + "adventureId": 500377997389, + "adventureLeaderboardId": 500377997389, + "difficulty": 5, + "lockedCaptainAssetId": null, + "losses": 104177, + "pointValue": 51, + "totalPlays": 157748, + "updated": "2013-06-18T18:13:21", + "wins": 53571 + }, + "assetFunction": "ADV_PUZZLE", + "assetId": 500377997389, + "auditTrail": null, + "author": { + "assetCount": 128, + "avatarImage": "thumb/500/335/938/500335938963.png", + "avatarImageCustom": false, + "dateCreated": "2008-06-18T01:57:00", + "default": true, + "id": 2262951433, + "lastLogin": "2012-05-25T00:52:05", + "name": "Doomwaffle", + "newestAssetCreated": "2012-03-22T21:23:00", + "nucleusUserId": 2262951433, + "personaId": 173842184, + "screenName": "Doomwaffle", + "subscriptionCount": 359, + "tagline": "Galactic Adventurer", + "updated": "2009-06-26T04:19:36", + "userId": 2262951433 + }, + "created": "2009-06-26T16:55:48", + "description": "A psychic entity has you at its disposal. What will it have you do? Now actually working! Thanks for making this a rising star guys.EDIT: I haven't checked out this in a while! Thanks for making this on the TOP PAGE! ", + "featured": "2009-07-08T00:00:00", + "id": 500377997389, + "imageCount": 2, + "localeString": "en_US", + "name": "The Psychic Planet", + "originalId": 500377997764, + "parentId": 500377997764, + "quality": true, + "rating": 14.376374, + "requiredProducts": [ + "EXPANSION_PACK1", + "INSECT_LIMBS", + "SPORE_CORE" + ], + "sourceIp": "98.203.139.225", + "status": { + "declaringClass": { + "name": "com.ea.sp.pollinator.db.Asset$Status" + }, + "name": "CLASSIFIED", + "nameKey": "asset.status.classified" + }, + "tags": "cool,fun,lava,psychic,puzzle,test", + "thumbnailSize": 41862, + "type": "ADVENTURE", + "updated": "2016-04-28T15:45:14" + } + ], + "resultsPerType": { + "class com.ea.sp.pollinator.db.Asset": [ + { + "adventureStat": { + "adventureId": 500377997389, + "adventureLeaderboardId": 500377997389, + "difficulty": 5, + "lockedCaptainAssetId": null, + "losses": 104177, + "pointValue": 51, + "totalPlays": 157748, + "updated": "2013-06-18T18:13:21", + "wins": 53571 + }, + "assetFunction": "ADV_PUZZLE", + "assetId": 500377997389, + "auditTrail": null, + "author": { + "assetCount": 128, + "avatarImage": "thumb/500/335/938/500335938963.png", + "avatarImageCustom": false, + "dateCreated": "2008-06-18T01:57:00", + "default": true, + "id": 2262951433, + "lastLogin": "2012-05-25T00:52:05", + "name": "Doomwaffle", + "newestAssetCreated": "2012-03-22T21:23:00", + "nucleusUserId": 2262951433, + "personaId": 173842184, + "screenName": "Doomwaffle", + "subscriptionCount": 359, + "tagline": "Galactic Adventurer", + "updated": "2009-06-26T04:19:36", + "userId": 2262951433 + }, + "created": "2009-06-26T16:55:48", + "description": "A psychic entity has you at its disposal. What will it have you do? Now actually working! Thanks for making this a rising star guys.EDIT: I haven't checked out this in a while! Thanks for making this on the TOP PAGE! ", + "featured": "2009-07-08T00:00:00", + "id": 500377997389, + "imageCount": 2, + "localeString": "en_US", + "name": "The Psychic Planet", + "originalId": 500377997764, + "parentId": 500377997764, + "quality": true, + "rating": 14.376374, + "requiredProducts": [ + "EXPANSION_PACK1", + "INSECT_LIMBS", + "SPORE_CORE" + ], + "sourceIp": "98.203.139.225", + "status": { + "declaringClass": { + "name": "com.ea.sp.pollinator.db.Asset$Status" + }, + "name": "CLASSIFIED", + "nameKey": "asset.status.classified" + }, + "tags": "cool,fun,lava,psychic,puzzle,test", + "thumbnailSize": 41862, + "type": "ADVENTURE", + "updated": "2016-04-28T15:45:14" + } + ] + } +} \ No newline at end of file diff --git a/tox.ini b/tox.ini index 8fd0dc6..7b83430 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,44 @@ -# tox (https://tox.readthedocs.io/) is a tool for running tests -# in multiple virtualenvs. This configuration file will run the -# test suite on all supported python versions. To use it, "pip install tox" -# and then run "tox" from this directory. - [tox] envlist = + py38, + py39, + py310, + linters, + coverage, + +isolated_build = true + + +[gh-actions] +python = + 3.8: py38, linters, coverage + 3.9: py39 + 3.10: py310 [testenv] +deps = coverage + +commands = + python3 -m unittest discover tests "test_*" + + + +[testenv:linters] deps = + flake8 + mypy +depends = py38, commands = - python -m unittest discover tests + flake8 sporepedia/ + mypy sporepedia/ + + +[testenv:coverage] +deps = coverage + +commands = + coverage run -m unittest discover tests "test_*" + coverage report --fail-under=0 -m + coverage html +