From 7ed25c1a7d6199365b552d73f8aabbd807865c9b Mon Sep 17 00:00:00 2001 From: Raventric <78981416+Ravencentric@users.noreply.github.com> Date: Thu, 4 Jul 2024 17:27:42 +0530 Subject: [PATCH] feat!: AniList.get() now accepts every parameter accepted by anilist BREAKING CHANGE: .search() is removed because it was redundant --- docs/api-reference/enums.md | 1 + docs/api-reference/exceptions.md | 119 +------ docs/api-reference/types.md | 15 +- mkdocs.yml | 5 +- poetry.lock | 197 +++++++----- pyproject.toml | 2 +- src/pyanilist/__init__.py | 69 +--- src/pyanilist/_clients/__init__.py | 2 + src/pyanilist/_clients/_async.py | 497 ++++++++++++++++------------- src/pyanilist/_clients/_sync.py | 495 ++++++++++++++++------------ src/pyanilist/_compat.py | 1 + src/pyanilist/_enums.py | 47 +++ src/pyanilist/_exceptions.py | 67 ---- src/pyanilist/_models.py | 20 +- src/pyanilist/_parser.py | 125 ++++++++ src/pyanilist/_query.py | 145 ++++++++- src/pyanilist/_types.py | 30 +- src/pyanilist/_utils.py | 88 +++++ src/pyanilist/_version.py | 12 +- tests/test_anilist.py | 29 +- tests/test_async_anilist.py | 29 +- tests/test_async_exceptions.py | 6 +- tests/test_exceptions.py | 6 +- tests/test_models.py | 7 +- 24 files changed, 1167 insertions(+), 847 deletions(-) delete mode 100644 src/pyanilist/_exceptions.py create mode 100644 src/pyanilist/_parser.py diff --git a/docs/api-reference/enums.md b/docs/api-reference/enums.md index c2a331a..26fda4b 100644 --- a/docs/api-reference/enums.md +++ b/docs/api-reference/enums.md @@ -5,6 +5,7 @@ ::: pyanilist._enums.MediaRankType ::: pyanilist._enums.MediaRelation ::: pyanilist._enums.MediaSeason +::: pyanilist._enums.MediaSort ::: pyanilist._enums.MediaSource ::: pyanilist._enums.MediaStatus ::: pyanilist._enums.MediaType \ No newline at end of file diff --git a/docs/api-reference/exceptions.md b/docs/api-reference/exceptions.md index 03e9f18..c6ad7ae 100644 --- a/docs/api-reference/exceptions.md +++ b/docs/api-reference/exceptions.md @@ -1,117 +1,4 @@ -!!! note - PyAniList simply re-exports exceptions from - [`httpx`](https://www.python-httpx.org/exceptions/) and - [`pydantic`](https://docs.pydantic.dev/latest/api/pydantic_core/#pydantic_core.ValidationError) for convenience. +PyAniList doesn't raise any custom exceptions of it's own. The two most likely errors you'll encounter will be either of these: -::: pyanilist._exceptions.CloseError - options: - show_source: false - members: false -::: pyanilist._exceptions.ConnectError - options: - show_source: false - members: false -::: pyanilist._exceptions.ConnectTimeout - options: - show_source: false - members: false -::: pyanilist._exceptions.CookieConflict - options: - show_source: false - members: false -::: pyanilist._exceptions.DecodingError - options: - show_source: false - members: false -::: pyanilist._exceptions.HTTPError - options: - show_source: false - members: false -::: pyanilist._exceptions.HTTPStatusError - options: - show_source: false - members: false -::: pyanilist._exceptions.InvalidURL - options: - show_source: false - members: false -::: pyanilist._exceptions.LocalProtocolError - options: - show_source: false - members: false -::: pyanilist._exceptions.NetworkError - options: - show_source: false - members: false -::: pyanilist._exceptions.PoolTimeout - options: - show_source: false - members: false -::: pyanilist._exceptions.ProtocolError - options: - show_source: false - members: false -::: pyanilist._exceptions.ProxyError - options: - show_source: false - members: false -::: pyanilist._exceptions.ReadError - options: - show_source: false - members: false -::: pyanilist._exceptions.ReadTimeout - options: - show_source: false - members: false -::: pyanilist._exceptions.RemoteProtocolError - options: - show_source: false - members: false -::: pyanilist._exceptions.RequestError - options: - show_source: false - members: false -::: pyanilist._exceptions.RequestNotRead - options: - show_source: false - members: false -::: pyanilist._exceptions.ResponseNotRead - options: - show_source: false - members: false -::: pyanilist._exceptions.StreamClosed - options: - show_source: false - members: false -::: pyanilist._exceptions.StreamConsumed - options: - show_source: false - members: false -::: pyanilist._exceptions.StreamError - options: - show_source: false - members: false -::: pyanilist._exceptions.TimeoutException - options: - show_source: false - members: false -::: pyanilist._exceptions.TooManyRedirects - options: - show_source: false - members: false -::: pyanilist._exceptions.UnsupportedProtocol - options: - show_source: false - members: false -::: pyanilist._exceptions.WriteError - options: - show_source: false - members: false -::: pyanilist._exceptions.WriteTimeout - options: - show_source: false - members: false -::: pyanilist._exceptions.ValidationError - options: - show_source: false - members: false \ No newline at end of file +- `pyanilist.HTTPStatusError` - Alias for [`httpx.HTTPStatusError`](https://www.python-httpx.org/exceptions/). Raised if a request returns a non 2xx status code. +- `pyanilist.ValidationError` - Alias for [`pydantic.ValidationError`](https://docs.pydantic.dev/latest/errors/validation_errors/). Raised if an input is invalid. diff --git a/docs/api-reference/types.md b/docs/api-reference/types.md index bb124b3..d5a1a49 100644 --- a/docs/api-reference/types.md +++ b/docs/api-reference/types.md @@ -1,15 +1,2 @@ -!!! note - Additionally, PyAniList also uses and exports the following [pydantic](https://docs.pydantic.dev/latest/) types for convenience: - - - [HttpUrl](https://docs.pydantic.dev/latest/api/networks/#pydantic.networks.HttpUrl) - - - [Color](https://docs.pydantic.dev/latest/api/pydantic_extra_types_color/#pydantic_extra_types.color.Color) - - - [CountryAlpha2 as CountryCode](https://docs.pydantic.dev/latest/api/pydantic_extra_types_country/#pydantic_extra_types.country.CountryAlpha2) - -::: pyanilist._types.AniListID -::: pyanilist._types.AniListTitle -::: pyanilist._types.AniListYear ::: pyanilist._types.YearsActive -::: pyanilist._types.HTTPXAsyncClientKwargs -::: pyanilist._types.HTTPXClientKwargs +::: pyanilist._types.FuzzyDateInt diff --git a/mkdocs.yml b/mkdocs.yml index a9d1a64..c6dcc99 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -47,7 +47,6 @@ plugins: options: allow_inspection: false docstring_style: numpy - members: true show_root_heading: true show_root_full_path: false show_signature_annotations: true @@ -56,9 +55,7 @@ plugins: show_symbol_type_toc: true signature_crossrefs: true merge_init_into_class: true - preload_modules: - - httpx - - pydantic + filters: ["!^_", "^__init__$"] markdown_extensions: - md_in_html diff --git a/poetry.lock b/poetry.lock index cfd36c0..8fa481f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "annotated-types" @@ -923,109 +923,121 @@ files = [ [[package]] name = "pydantic" -version = "2.7.1" +version = "2.8.0" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.7.1-py3-none-any.whl", hash = "sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5"}, - {file = "pydantic-2.7.1.tar.gz", hash = "sha256:e9dbb5eada8abe4d9ae5f46b9939aead650cd2b68f249bb3a8139dbe125803cc"}, + {file = "pydantic-2.8.0-py3-none-any.whl", hash = "sha256:ead4f3a1e92386a734ca1411cb25d94147cf8778ed5be6b56749047676d6364e"}, + {file = "pydantic-2.8.0.tar.gz", hash = "sha256:d970ffb9d030b710795878940bd0489842c638e7252fc4a19c3ae2f7da4d6141"}, ] [package.dependencies] annotated-types = ">=0.4.0" -pydantic-core = "2.18.2" -typing-extensions = ">=4.6.1" +pydantic-core = "2.20.0" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] [package.extras] email = ["email-validator (>=2.0.0)"] [[package]] name = "pydantic-core" -version = "2.18.2" +version = "2.20.0" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.18.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9e08e867b306f525802df7cd16c44ff5ebbe747ff0ca6cf3fde7f36c05a59a81"}, - {file = "pydantic_core-2.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f0a21cbaa69900cbe1a2e7cad2aa74ac3cf21b10c3efb0fa0b80305274c0e8a2"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0680b1f1f11fda801397de52c36ce38ef1c1dc841a0927a94f226dea29c3ae3d"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:95b9d5e72481d3780ba3442eac863eae92ae43a5f3adb5b4d0a1de89d42bb250"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fcf5cd9c4b655ad666ca332b9a081112cd7a58a8b5a6ca7a3104bc950f2038"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b5155ff768083cb1d62f3e143b49a8a3432e6789a3abee8acd005c3c7af1c74"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553ef617b6836fc7e4df130bb851e32fe357ce36336d897fd6646d6058d980af"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89ed9eb7d616ef5714e5590e6cf7f23b02d0d539767d33561e3675d6f9e3857"}, - {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:75f7e9488238e920ab6204399ded280dc4c307d034f3924cd7f90a38b1829563"}, - {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ef26c9e94a8c04a1b2924149a9cb081836913818e55681722d7f29af88fe7b38"}, - {file = "pydantic_core-2.18.2-cp310-none-win32.whl", hash = "sha256:182245ff6b0039e82b6bb585ed55a64d7c81c560715d1bad0cbad6dfa07b4027"}, - {file = "pydantic_core-2.18.2-cp310-none-win_amd64.whl", hash = "sha256:e23ec367a948b6d812301afc1b13f8094ab7b2c280af66ef450efc357d2ae543"}, - {file = "pydantic_core-2.18.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:219da3f096d50a157f33645a1cf31c0ad1fe829a92181dd1311022f986e5fbe3"}, - {file = "pydantic_core-2.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc1cfd88a64e012b74e94cd00bbe0f9c6df57049c97f02bb07d39e9c852e19a4"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b7133a6e6aeb8df37d6f413f7705a37ab4031597f64ab56384c94d98fa0e90"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:224c421235f6102e8737032483f43c1a8cfb1d2f45740c44166219599358c2cd"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b14d82cdb934e99dda6d9d60dc84a24379820176cc4a0d123f88df319ae9c150"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2728b01246a3bba6de144f9e3115b532ee44bd6cf39795194fb75491824a1413"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:470b94480bb5ee929f5acba6995251ada5e059a5ef3e0dfc63cca287283ebfa6"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:997abc4df705d1295a42f95b4eec4950a37ad8ae46d913caeee117b6b198811c"}, - {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75250dbc5290e3f1a0f4618db35e51a165186f9034eff158f3d490b3fed9f8a0"}, - {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4456f2dca97c425231d7315737d45239b2b51a50dc2b6f0c2bb181fce6207664"}, - {file = "pydantic_core-2.18.2-cp311-none-win32.whl", hash = "sha256:269322dcc3d8bdb69f054681edff86276b2ff972447863cf34c8b860f5188e2e"}, - {file = "pydantic_core-2.18.2-cp311-none-win_amd64.whl", hash = "sha256:800d60565aec896f25bc3cfa56d2277d52d5182af08162f7954f938c06dc4ee3"}, - {file = "pydantic_core-2.18.2-cp311-none-win_arm64.whl", hash = "sha256:1404c69d6a676245199767ba4f633cce5f4ad4181f9d0ccb0577e1f66cf4c46d"}, - {file = "pydantic_core-2.18.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fb2bd7be70c0fe4dfd32c951bc813d9fe6ebcbfdd15a07527796c8204bd36242"}, - {file = "pydantic_core-2.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6132dd3bd52838acddca05a72aafb6eab6536aa145e923bb50f45e78b7251043"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d904828195733c183d20a54230c0df0eb46ec746ea1a666730787353e87182"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9bd70772c720142be1020eac55f8143a34ec9f82d75a8e7a07852023e46617f"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8ed04b3582771764538f7ee7001b02e1170223cf9b75dff0bc698fadb00cf3"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6dac87ddb34aaec85f873d737e9d06a3555a1cc1a8e0c44b7f8d5daeb89d86f"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ca4ae5a27ad7a4ee5170aebce1574b375de390bc01284f87b18d43a3984df72"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:886eec03591b7cf058467a70a87733b35f44707bd86cf64a615584fd72488b7c"}, - {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ca7b0c1f1c983e064caa85f3792dd2fe3526b3505378874afa84baf662e12241"}, - {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b4356d3538c3649337df4074e81b85f0616b79731fe22dd11b99499b2ebbdf3"}, - {file = "pydantic_core-2.18.2-cp312-none-win32.whl", hash = "sha256:8b172601454f2d7701121bbec3425dd71efcb787a027edf49724c9cefc14c038"}, - {file = "pydantic_core-2.18.2-cp312-none-win_amd64.whl", hash = "sha256:b1bd7e47b1558ea872bd16c8502c414f9e90dcf12f1395129d7bb42a09a95438"}, - {file = "pydantic_core-2.18.2-cp312-none-win_arm64.whl", hash = "sha256:98758d627ff397e752bc339272c14c98199c613f922d4a384ddc07526c86a2ec"}, - {file = "pydantic_core-2.18.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:9fdad8e35f278b2c3eb77cbdc5c0a49dada440657bf738d6905ce106dc1de439"}, - {file = "pydantic_core-2.18.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1d90c3265ae107f91a4f279f4d6f6f1d4907ac76c6868b27dc7fb33688cfb347"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390193c770399861d8df9670fb0d1874f330c79caaca4642332df7c682bf6b91"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:82d5d4d78e4448683cb467897fe24e2b74bb7b973a541ea1dcfec1d3cbce39fb"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4774f3184d2ef3e14e8693194f661dea5a4d6ca4e3dc8e39786d33a94865cefd"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4d938ec0adf5167cb335acb25a4ee69a8107e4984f8fbd2e897021d9e4ca21b"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0e8b1be28239fc64a88a8189d1df7fad8be8c1ae47fcc33e43d4be15f99cc70"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:868649da93e5a3d5eacc2b5b3b9235c98ccdbfd443832f31e075f54419e1b96b"}, - {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:78363590ef93d5d226ba21a90a03ea89a20738ee5b7da83d771d283fd8a56761"}, - {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:852e966fbd035a6468fc0a3496589b45e2208ec7ca95c26470a54daed82a0788"}, - {file = "pydantic_core-2.18.2-cp38-none-win32.whl", hash = "sha256:6a46e22a707e7ad4484ac9ee9f290f9d501df45954184e23fc29408dfad61350"}, - {file = "pydantic_core-2.18.2-cp38-none-win_amd64.whl", hash = "sha256:d91cb5ea8b11607cc757675051f61b3d93f15eca3cefb3e6c704a5d6e8440f4e"}, - {file = "pydantic_core-2.18.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ae0a8a797a5e56c053610fa7be147993fe50960fa43609ff2a9552b0e07013e8"}, - {file = "pydantic_core-2.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:042473b6280246b1dbf530559246f6842b56119c2926d1e52b631bdc46075f2a"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a388a77e629b9ec814c1b1e6b3b595fe521d2cdc625fcca26fbc2d44c816804"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25add29b8f3b233ae90ccef2d902d0ae0432eb0d45370fe315d1a5cf231004b"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f459a5ce8434614dfd39bbebf1041952ae01da6bed9855008cb33b875cb024c0"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eff2de745698eb46eeb51193a9f41d67d834d50e424aef27df2fcdee1b153845"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8309f67285bdfe65c372ea3722b7a5642680f3dba538566340a9d36e920b5f0"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f93a8a2e3938ff656a7c1bc57193b1319960ac015b6e87d76c76bf14fe0244b4"}, - {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:22057013c8c1e272eb8d0eebc796701167d8377441ec894a8fed1af64a0bf399"}, - {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfeecd1ac6cc1fb2692c3d5110781c965aabd4ec5d32799773ca7b1456ac636b"}, - {file = "pydantic_core-2.18.2-cp39-none-win32.whl", hash = "sha256:0d69b4c2f6bb3e130dba60d34c0845ba31b69babdd3f78f7c0c8fae5021a253e"}, - {file = "pydantic_core-2.18.2-cp39-none-win_amd64.whl", hash = "sha256:d9319e499827271b09b4e411905b24a426b8fb69464dfa1696258f53a3334641"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a1874c6dd4113308bd0eb568418e6114b252afe44319ead2b4081e9b9521fe75"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ccdd111c03bfd3666bd2472b674c6899550e09e9f298954cfc896ab92b5b0e6d"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e18609ceaa6eed63753037fc06ebb16041d17d28199ae5aba0052c51449650a9"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e5c584d357c4e2baf0ff7baf44f4994be121e16a2c88918a5817331fc7599d7"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43f0f463cf89ace478de71a318b1b4f05ebc456a9b9300d027b4b57c1a2064fb"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e1b395e58b10b73b07b7cf740d728dd4ff9365ac46c18751bf8b3d8cca8f625a"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0098300eebb1c837271d3d1a2cd2911e7c11b396eac9661655ee524a7f10587b"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:36789b70d613fbac0a25bb07ab3d9dba4d2e38af609c020cf4d888d165ee0bf3"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3f9a801e7c8f1ef8718da265bba008fa121243dfe37c1cea17840b0944dfd72c"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3a6515ebc6e69d85502b4951d89131ca4e036078ea35533bb76327f8424531ce"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20aca1e2298c56ececfd8ed159ae4dde2df0781988c97ef77d5c16ff4bd5b400"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:223ee893d77a310a0391dca6df00f70bbc2f36a71a895cecd9a0e762dc37b349"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2334ce8c673ee93a1d6a65bd90327588387ba073c17e61bf19b4fd97d688d63c"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cbca948f2d14b09d20268cda7b0367723d79063f26c4ffc523af9042cad95592"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b3ef08e20ec49e02d5c6717a91bb5af9b20f1805583cb0adfe9ba2c6b505b5ae"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6fdc8627910eed0c01aed6a390a252fe3ea6d472ee70fdde56273f198938374"}, - {file = "pydantic_core-2.18.2.tar.gz", hash = "sha256:2e29d20810dfc3043ee13ac7d9e25105799817683348823f305ab3f349b9386e"}, + {file = "pydantic_core-2.20.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:e9dcd7fb34f7bfb239b5fa420033642fff0ad676b765559c3737b91f664d4fa9"}, + {file = "pydantic_core-2.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:649a764d9b0da29816889424697b2a3746963ad36d3e0968784ceed6e40c6355"}, + {file = "pydantic_core-2.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7701df088d0b05f3460f7ba15aec81ac8b0fb5690367dfd072a6c38cf5b7fdb5"}, + {file = "pydantic_core-2.20.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab760f17c3e792225cdaef31ca23c0aea45c14ce80d8eff62503f86a5ab76bff"}, + {file = "pydantic_core-2.20.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cb1ad5b4d73cde784cf64580166568074f5ccd2548d765e690546cff3d80937d"}, + {file = "pydantic_core-2.20.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b81ec2efc04fc1dbf400647d4357d64fb25543bae38d2d19787d69360aad21c9"}, + {file = "pydantic_core-2.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4a9732a5cad764ba37f3aa873dccb41b584f69c347a57323eda0930deec8e10"}, + {file = "pydantic_core-2.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6dc85b9e10cc21d9c1055f15684f76fa4facadddcb6cd63abab702eb93c98943"}, + {file = "pydantic_core-2.20.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:21d9f7e24f63fdc7118e6cc49defaab8c1d27570782f7e5256169d77498cf7c7"}, + {file = "pydantic_core-2.20.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8b315685832ab9287e6124b5d74fc12dda31e6421d7f6b08525791452844bc2d"}, + {file = "pydantic_core-2.20.0-cp310-none-win32.whl", hash = "sha256:c3dc8ec8b87c7ad534c75b8855168a08a7036fdb9deeeed5705ba9410721c84d"}, + {file = "pydantic_core-2.20.0-cp310-none-win_amd64.whl", hash = "sha256:85770b4b37bb36ef93a6122601795231225641003e0318d23c6233c59b424279"}, + {file = "pydantic_core-2.20.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:58e251bb5a5998f7226dc90b0b753eeffa720bd66664eba51927c2a7a2d5f32c"}, + {file = "pydantic_core-2.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:78d584caac52c24240ef9ecd75de64c760bbd0e20dbf6973631815e3ef16ef8b"}, + {file = "pydantic_core-2.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5084ec9721f82bef5ff7c4d1ee65e1626783abb585f8c0993833490b63fe1792"}, + {file = "pydantic_core-2.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6d0f52684868db7c218437d260e14d37948b094493f2646f22d3dda7229bbe3f"}, + {file = "pydantic_core-2.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1def125d59a87fe451212a72ab9ed34c118ff771e5473fef4f2f95d8ede26d75"}, + {file = "pydantic_core-2.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b34480fd6778ab356abf1e9086a4ced95002a1e195e8d2fd182b0def9d944d11"}, + {file = "pydantic_core-2.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d42669d319db366cb567c3b444f43caa7ffb779bf9530692c6f244fc635a41eb"}, + {file = "pydantic_core-2.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53b06aea7a48919a254b32107647be9128c066aaa6ee6d5d08222325f25ef175"}, + {file = "pydantic_core-2.20.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1f038156b696a1c39d763b2080aeefa87ddb4162c10aa9fabfefffc3dd8180fa"}, + {file = "pydantic_core-2.20.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3f0f3a4a23717280a5ee3ac4fb1f81d6fde604c9ec5100f7f6f987716bb8c137"}, + {file = "pydantic_core-2.20.0-cp311-none-win32.whl", hash = "sha256:316fe7c3fec017affd916a0c83d6f1ec697cbbbdf1124769fa73328e7907cc2e"}, + {file = "pydantic_core-2.20.0-cp311-none-win_amd64.whl", hash = "sha256:2d06a7fa437f93782e3f32d739c3ec189f82fca74336c08255f9e20cea1ed378"}, + {file = "pydantic_core-2.20.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d6f8c49657f3eb7720ed4c9b26624063da14937fc94d1812f1e04a2204db3e17"}, + {file = "pydantic_core-2.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ad1bd2f377f56fec11d5cfd0977c30061cd19f4fa199bf138b200ec0d5e27eeb"}, + {file = "pydantic_core-2.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed741183719a5271f97d93bbcc45ed64619fa38068aaa6e90027d1d17e30dc8d"}, + {file = "pydantic_core-2.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d82e5ed3a05f2dcb89c6ead2fd0dbff7ac09bc02c1b4028ece2d3a3854d049ce"}, + {file = "pydantic_core-2.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2ba34a099576234671f2e4274e5bc6813b22e28778c216d680eabd0db3f7dad"}, + {file = "pydantic_core-2.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:879ae6bb08a063b3e1b7ac8c860096d8fd6b48dd9b2690b7f2738b8c835e744b"}, + {file = "pydantic_core-2.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b0eefc7633a04c0694340aad91fbfd1986fe1a1e0c63a22793ba40a18fcbdc8"}, + {file = "pydantic_core-2.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73deadd6fd8a23e2f40b412b3ac617a112143c8989a4fe265050fd91ba5c0608"}, + {file = "pydantic_core-2.20.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:35681445dc85446fb105943d81ae7569aa7e89de80d1ca4ac3229e05c311bdb1"}, + {file = "pydantic_core-2.20.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0f6dd3612a3b9f91f2e63924ea18a4476656c6d01843ca20a4c09e00422195af"}, + {file = "pydantic_core-2.20.0-cp312-none-win32.whl", hash = "sha256:7e37b6bb6e90c2b8412b06373c6978d9d81e7199a40e24a6ef480e8acdeaf918"}, + {file = "pydantic_core-2.20.0-cp312-none-win_amd64.whl", hash = "sha256:7d4df13d1c55e84351fab51383520b84f490740a9f1fec905362aa64590b7a5d"}, + {file = "pydantic_core-2.20.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d43e7ab3b65e4dc35a7612cfff7b0fd62dce5bc11a7cd198310b57f39847fd6c"}, + {file = "pydantic_core-2.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b6a24d7b5893392f2b8e3b7a0031ae3b14c6c1942a4615f0d8794fdeeefb08b"}, + {file = "pydantic_core-2.20.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b2f13c3e955a087c3ec86f97661d9f72a76e221281b2262956af381224cfc243"}, + {file = "pydantic_core-2.20.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72432fd6e868c8d0a6849869e004b8bcae233a3c56383954c228316694920b38"}, + {file = "pydantic_core-2.20.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d70a8ff2d4953afb4cbe6211f17268ad29c0b47e73d3372f40e7775904bc28fc"}, + {file = "pydantic_core-2.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e49524917b8d3c2f42cd0d2df61178e08e50f5f029f9af1f402b3ee64574392"}, + {file = "pydantic_core-2.20.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4f0f71653b1c1bad0350bc0b4cc057ab87b438ff18fa6392533811ebd01439c"}, + {file = "pydantic_core-2.20.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:16197e6f4fdecb9892ed2436e507e44f0a1aa2cff3b9306d1c879ea2f9200997"}, + {file = "pydantic_core-2.20.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:763602504bf640b3ded3bba3f8ed8a1cc2fc6a87b8d55c1c5689f428c49c947e"}, + {file = "pydantic_core-2.20.0-cp313-none-win32.whl", hash = "sha256:a3f243f318bd9523277fa123b3163f4c005a3e8619d4b867064de02f287a564d"}, + {file = "pydantic_core-2.20.0-cp313-none-win_amd64.whl", hash = "sha256:03aceaf6a5adaad3bec2233edc5a7905026553916615888e53154807e404545c"}, + {file = "pydantic_core-2.20.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d6f2d8b8da1f03f577243b07bbdd3412eee3d37d1f2fd71d1513cbc76a8c1239"}, + {file = "pydantic_core-2.20.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a272785a226869416c6b3c1b7e450506152d3844207331f02f27173562c917e0"}, + {file = "pydantic_core-2.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efbb412d55a4ffe73963fed95c09ccb83647ec63b711c4b3752be10a56f0090b"}, + {file = "pydantic_core-2.20.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1e4f46189d8740561b43655263a41aac75ff0388febcb2c9ec4f1b60a0ec12f3"}, + {file = "pydantic_core-2.20.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87d3df115f4a3c8c5e4d5acf067d399c6466d7e604fc9ee9acbe6f0c88a0c3cf"}, + {file = "pydantic_core-2.20.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a340d2bdebe819d08f605e9705ed551c3feb97e4fd71822d7147c1e4bdbb9508"}, + {file = "pydantic_core-2.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:616b9c2f882393d422ba11b40e72382fe975e806ad693095e9a3b67c59ea6150"}, + {file = "pydantic_core-2.20.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25c46bb2ff6084859bbcfdf4f1a63004b98e88b6d04053e8bf324e115398e9e7"}, + {file = "pydantic_core-2.20.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:23425eccef8f2c342f78d3a238c824623836c6c874d93c726673dbf7e56c78c0"}, + {file = "pydantic_core-2.20.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:52527e8f223ba29608d999d65b204676398009725007c9336651c2ec2d93cffc"}, + {file = "pydantic_core-2.20.0-cp38-none-win32.whl", hash = "sha256:1c3c5b7f70dd19a6845292b0775295ea81c61540f68671ae06bfe4421b3222c2"}, + {file = "pydantic_core-2.20.0-cp38-none-win_amd64.whl", hash = "sha256:8093473d7b9e908af1cef30025609afc8f5fd2a16ff07f97440fd911421e4432"}, + {file = "pydantic_core-2.20.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ee7785938e407418795e4399b2bf5b5f3cf6cf728077a7f26973220d58d885cf"}, + {file = "pydantic_core-2.20.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0e75794883d635071cf6b4ed2a5d7a1e50672ab7a051454c76446ef1ebcdcc91"}, + {file = "pydantic_core-2.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:344e352c96e53b4f56b53d24728217c69399b8129c16789f70236083c6ceb2ac"}, + {file = "pydantic_core-2.20.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:978d4123ad1e605daf1ba5e01d4f235bcf7b6e340ef07e7122e8e9cfe3eb61ab"}, + {file = "pydantic_core-2.20.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c05eaf6c863781eb834ab41f5963604ab92855822a2062897958089d1335dad"}, + {file = "pydantic_core-2.20.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc7e43b4a528ffca8c9151b6a2ca34482c2fdc05e6aa24a84b7f475c896fc51d"}, + {file = "pydantic_core-2.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:658287a29351166510ebbe0a75c373600cc4367a3d9337b964dada8d38bcc0f4"}, + {file = "pydantic_core-2.20.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1dacf660d6de692fe351e8c806e7efccf09ee5184865893afbe8e59be4920b4a"}, + {file = "pydantic_core-2.20.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3e147fc6e27b9a487320d78515c5f29798b539179f7777018cedf51b7749e4f4"}, + {file = "pydantic_core-2.20.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c867230d715a3dd1d962c8d9bef0d3168994ed663e21bf748b6e3a529a129aab"}, + {file = "pydantic_core-2.20.0-cp39-none-win32.whl", hash = "sha256:22b813baf0dbf612752d8143a2dbf8e33ccb850656b7850e009bad2e101fc377"}, + {file = "pydantic_core-2.20.0-cp39-none-win_amd64.whl", hash = "sha256:3a7235b46c1bbe201f09b6f0f5e6c36b16bad3d0532a10493742f91fbdc8035f"}, + {file = "pydantic_core-2.20.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cafde15a6f7feaec2f570646e2ffc5b73412295d29134a29067e70740ec6ee20"}, + {file = "pydantic_core-2.20.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:2aec8eeea0b08fd6bc2213d8e86811a07491849fd3d79955b62d83e32fa2ad5f"}, + {file = "pydantic_core-2.20.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:840200827984f1c4e114008abc2f5ede362d6e11ed0b5931681884dd41852ff1"}, + {file = "pydantic_core-2.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8ea1d8b7df522e5ced34993c423c3bf3735c53df8b2a15688a2f03a7d678800"}, + {file = "pydantic_core-2.20.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d5b8376a867047bf08910573deb95d3c8dfb976eb014ee24f3b5a61ccc5bee1b"}, + {file = "pydantic_core-2.20.0-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d08264b4460326cefacc179fc1411304d5af388a79910832835e6f641512358b"}, + {file = "pydantic_core-2.20.0-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:7a3639011c2e8a9628466f616ed7fb413f30032b891898e10895a0a8b5857d6c"}, + {file = "pydantic_core-2.20.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05e83ce2f7eba29e627dd8066aa6c4c0269b2d4f889c0eba157233a353053cea"}, + {file = "pydantic_core-2.20.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:603a843fea76a595c8f661cd4da4d2281dff1e38c4a836a928eac1a2f8fe88e4"}, + {file = "pydantic_core-2.20.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ac76f30d5d3454f4c28826d891fe74d25121a346c69523c9810ebba43f3b1cec"}, + {file = "pydantic_core-2.20.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e3b1d4b1b3f6082849f9b28427ef147a5b46a6132a3dbaf9ca1baa40c88609"}, + {file = "pydantic_core-2.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2761f71faed820e25ec62eacba670d1b5c2709bb131a19fcdbfbb09884593e5a"}, + {file = "pydantic_core-2.20.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a0586cddbf4380e24569b8a05f234e7305717cc8323f50114dfb2051fcbce2a3"}, + {file = "pydantic_core-2.20.0-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:b8c46a8cf53e849eea7090f331ae2202cd0f1ceb090b00f5902c423bd1e11805"}, + {file = "pydantic_core-2.20.0-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b4a085bd04af7245e140d1b95619fe8abb445a3d7fdf219b3f80c940853268ef"}, + {file = "pydantic_core-2.20.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:116b326ac82c8b315e7348390f6d30bcfe6e688a7d3f1de50ff7bcc2042a23c2"}, + {file = "pydantic_core-2.20.0.tar.gz", hash = "sha256:366be8e64e0cb63d87cf79b4e1765c0703dd6313c729b22e7b9e378db6b96877"}, ] [package.dependencies] @@ -1464,6 +1476,17 @@ files = [ {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, ] +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + [[package]] name = "urllib3" version = "2.2.1" @@ -1560,4 +1583,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.9" -content-hash = "33b8e5e4f5ea85fdc3e362575eff7506824b6f6d60231145992ee6122446b0f9" +content-hash = "ea0748c83bcfb50bb02f0e5394a95f80d7730f8e7f622989675246ae5448d32f" diff --git a/pyproject.toml b/pyproject.toml index d4d19c9..c06b63f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ [tool.poetry.dependencies] python = ">=3.9" -pydantic = ">=2.7.1" +pydantic = "^2.8.0" typing-extensions =">=4.11.0" pydantic-extra-types =">=2.7.0" pycountry = ">=23.12.11" diff --git a/src/pyanilist/__init__.py b/src/pyanilist/__init__.py index 61b9e67..1f39b2c 100644 --- a/src/pyanilist/__init__.py +++ b/src/pyanilist/__init__.py @@ -1,3 +1,8 @@ +from __future__ import annotations + +from httpx import HTTPStatusError +from pydantic import ValidationError + from ._clients import AniList, AsyncAniList from ._enums import ( CharacterRole, @@ -10,36 +15,6 @@ MediaStatus, MediaType, ) -from ._exceptions import ( - CloseError, - ConnectError, - ConnectTimeout, - CookieConflict, - DecodingError, - HTTPError, - HTTPStatusError, - InvalidURL, - LocalProtocolError, - NetworkError, - PoolTimeout, - ProtocolError, - ProxyError, - ReadError, - ReadTimeout, - RemoteProtocolError, - RequestError, - RequestNotRead, - ResponseNotRead, - StreamClosed, - StreamConsumed, - StreamError, - TimeoutException, - TooManyRedirects, - UnsupportedProtocol, - ValidationError, - WriteError, - WriteTimeout, -) from ._models import ( AiringSchedule, Character, @@ -61,7 +36,7 @@ StaffName, Studio, ) -from ._types import AniListID, AniListTitle, AniListYear, Color, CountryCode, HttpUrl, YearsActive +from ._types import YearsActive from ._version import Version, _get_version __version__ = _get_version() @@ -82,12 +57,6 @@ "MediaStatus", "MediaType", # Types - "AniListID", - "AniListTitle", - "AniListYear", - "Color", - "CountryCode", - "HttpUrl", "YearsActive", # Models "AiringSchedule", @@ -110,32 +79,6 @@ "StaffName", "Studio", # Exceptions - "CloseError", - "ConnectError", - "ConnectTimeout", - "CookieConflict", - "DecodingError", - "HTTPError", "HTTPStatusError", - "InvalidURL", - "LocalProtocolError", - "NetworkError", - "PoolTimeout", - "ProtocolError", - "ProxyError", - "ReadError", - "ReadTimeout", - "RemoteProtocolError", - "RequestError", - "RequestNotRead", - "ResponseNotRead", - "StreamClosed", - "StreamConsumed", - "StreamError", - "TimeoutException", - "TooManyRedirects", - "UnsupportedProtocol", - "WriteError", - "WriteTimeout", "ValidationError", ] diff --git a/src/pyanilist/_clients/__init__.py b/src/pyanilist/_clients/__init__.py index ce756d3..b9d4a57 100644 --- a/src/pyanilist/_clients/__init__.py +++ b/src/pyanilist/_clients/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from ._async import AsyncAniList from ._sync import AniList diff --git a/src/pyanilist/_clients/_async.py b/src/pyanilist/_clients/_async.py index dd5d824..bc8b6df 100644 --- a/src/pyanilist/_clients/_async.py +++ b/src/pyanilist/_clients/_async.py @@ -1,25 +1,26 @@ from __future__ import annotations +from collections.abc import Iterable from typing import Any -import httpx +from httpx import AsyncClient, HTTPError, Response from pydantic import PositiveInt, validate_call +from pydantic_extra_types.country import CountryAlpha2 as CountryCode from stamina import retry_context -from .._enums import MediaFormat, MediaSeason, MediaStatus, MediaType +from .._enums import MediaFormat, MediaSeason, MediaSort, MediaSource, MediaStatus, MediaType from .._models import Media +from .._parser import post_process_response from .._query import query_string -from .._types import AniListID, AniListTitle, AniListYear, HTTPXAsyncClientKwargs -from .._utils import flatten, markdown_formatter, remove_null_fields, sanitize_description, text_formatter +from .._types import FuzzyDateInt +from .._utils import query_variables_constructor class AsyncAniList: @validate_call - def __init__( - self, api_url: str = "https://graphql.anilist.co", retries: PositiveInt = 5, **kwargs: HTTPXAsyncClientKwargs - ) -> None: + def __init__(self, api_url: str = "https://graphql.anilist.co", retries: PositiveInt = 5, **kwargs: Any) -> None: """ - Async AniList API client. + AniList API client. Parameters ---------- @@ -28,43 +29,22 @@ def __init__( retries : PositiveInt, optional Number of times to retry a failed request before raising an error. Default is 5. Set this to 1 to disable retrying. - kwargs : HTTPXAsyncClientKwargs, optional - Keyword arguments to pass to the underlying [httpx.AsyncClient()](https://www.python-httpx.org/api/#asyncclient) + kwargs : Any, optional + Keyword arguments to pass to the underlying [`httpx.AsyncClient`](https://www.python-httpx.org/api/#asyncclient) used to make the POST request. """ self.api_url = api_url self.retries = retries self.kwargs = kwargs - async def _post_request( - self, - id: AniListID | None = None, - season: MediaSeason | None = None, - season_year: AniListYear | None = None, - type: MediaType | None = None, - format: MediaFormat | None = None, - status: MediaStatus | None = None, - title: AniListTitle | None = None, - ) -> httpx.Response: + async def _post_request(self, **kwargs: Any) -> Response: """ Make a POST request to the AniList API. Parameters ---------- - id : AniListID, optional - AniList ID of the media as found in the URL: `https://anilist.co/{type}/{id}`. Default is None. - season : MediaSeason | None, optional - The season the media was initially released in. Default is None. - season_year : AniListYear | None, optional - The season year the media was initially released in. Default is None. - type : MediaType | None, optional - The type of the media; anime or manga. Default is None. - format : MediaFormat | None, optional - The format the media was released in. Default is None. - status : MediaStatus | None, optional - The current releasing status of the media. Default is None. - title : AniListTitle | None, optional - The string used for searching on AniList. Default is None. + kwargs : Any + Anilist query kwargs Raises ------ @@ -78,175 +58,233 @@ async def _post_request( The response object from the AniList API. """ - # map params with AniList's - query_variables = dict( - id=id, - season=season, - seasonYear=season_year, - type=type, - format=format, - status=status, - search=title, - ) - payload = { "query": query_string, - "variables": {key: value for key, value in query_variables.items() if value is not None}, + "variables": query_variables_constructor(kwargs), } - async for attempt in retry_context(on=httpx.HTTPError, attempts=self.retries): + async for attempt in retry_context(on=HTTPError, attempts=self.retries): with attempt: - async with httpx.AsyncClient(**self.kwargs) as client: + async with AsyncClient(**self.kwargs) as client: response = await client.post(self.api_url, json=payload) response.raise_for_status() return response - @staticmethod - async def _process_description(dictionary: dict[str, Any]) -> dict[str, Any]: - """ - Anilist's description field takes a parameter `asHtml: boolean`, effectively - resulting in two differently formatted descriptions. - - Despite what the name implies, `asHtml` being `False` does not gurantee that there will be no `HTML` - tags in the description. - - So we do a bit of post processing: - - Sanitize the resulting descriptions - - Introduce two more formats derived from the original two, i.e, markdown and plain text - - Nest our newly acquired 4 descriptions into a single parent dictionary - - Example: - - This - ```py - { - "defaultDescription": "...", - "htmlDescription": "...", - } - ``` - Turns into this: - ```py - { - "description": { - "default": "...", - "html": "...", - "markdown": "...", - "text": "...", - } - } - ```py - - """ - - default_description = sanitize_description(dictionary.get("defaultDescription")) - html_description = sanitize_description(dictionary.get("htmlDescription")) - markdown_description = markdown_formatter(html_description) - text_description = text_formatter(default_description) - - # Delete the processed keys - dictionary.pop("defaultDescription", None) - dictionary.pop("htmlDescription", None) - - # Nest them inside a parent dictionary - return dict( - default=default_description, - html=html_description, - markdown=markdown_description, - text=text_description, - ) - - async def _post_process_response(self, response: httpx.Response) -> dict[str, Any]: - """ - Post-processes the response from AniList API. - - Parameters - ---------- - response : Response - The response object received from AniList API. - - Returns - ------- - dict[str, Any] - Processed dictionary containing media information. - - Notes - ----- - Currently, this function does two things: - 1. Flattening nested structures such as relations, studios, characters, and staff. - 2. Removing null fields to ensure a cleaner output. - """ - - # type hinting for IDE because it doesn't detect it automagically - dictionary: dict[str, Any] - - dictionary = response.json()["data"]["Media"] - - # "Flatten" the nested list of dictionaries of list of dictionaries... blah blah - # into simpler list for more intuitive access - relations = dictionary.get("relations") - dictionary.pop("relations", None) - flattened_relations = flatten(relations, "relationType") - - # same thing here - studios = dictionary.get("studios") - dictionary.pop("studios", None) - flattened_studios = flatten(studios, "isMain") - - # same thing here - characters = dictionary.get("characters") - dictionary.pop("characters", None) - flattened_characters = flatten(characters, "role") - - # same thing here - staff = dictionary.get("staff") - dictionary.pop("staff", None) - flattened_staff = flatten(staff, "role") - - # Process description - dictionary["description"] = await self._process_description(dictionary) - - # Process description of every relation - for relation in flattened_relations: - relation["description"] = await self._process_description(relation) - - # replace the original - dictionary["relations"] = flattened_relations - dictionary["studios"] = flattened_studios - dictionary["characters"] = flattened_characters - dictionary["staff"] = flattened_staff - - # self explanatory, details on why - # are in the function docstring - return remove_null_fields(dictionary) - @validate_call - async def search( + async def get( self, - title: AniListTitle, + search: str | None = None, + *, + id: int | None = None, + id_mal: int | None = None, + start_date: FuzzyDateInt | None = None, + end_date: FuzzyDateInt | None = None, season: MediaSeason | None = None, - season_year: AniListYear | None = None, + season_year: int | None = None, type: MediaType | None = None, format: MediaFormat | None = None, status: MediaStatus | None = None, + episodes: int | None = None, + chapters: int | None = None, + duration: int | None = None, + volumes: int | None = None, + is_adult: bool | None = None, + genre: str | None = None, + tag: str | None = None, + minimum_tag_rank: int | None = None, + tag_category: str | None = None, + licensed_by: str | None = None, + licensed_by_id: int | None = None, + average_score: int | None = None, + popularity: int | None = None, + source: MediaSource | None = None, + country_of_origin: CountryCode | str | None = None, + is_licensed: bool | None = None, + id_not: int | None = None, + id_in: Iterable[int] | None = None, + id_not_in: Iterable[int] | None = None, + id_mal_not: int | None = None, + id_mal_in: Iterable[int] | None = None, + id_mal_not_in: Iterable[int] | None = None, + start_date_greater: FuzzyDateInt | None = None, + start_date_lesser: FuzzyDateInt | None = None, + start_date_like: str | None = None, + end_date_greater: FuzzyDateInt | None = None, + end_date_lesser: FuzzyDateInt | None = None, + end_date_like: str | None = None, + format_in: Iterable[MediaFormat] | None = None, + format_not: MediaFormat | None = None, + format_not_in: Iterable[MediaFormat] | None = None, + status_in: Iterable[MediaStatus] | None = None, + status_not: MediaStatus | None = None, + status_not_in: Iterable[MediaStatus] | None = None, + episodes_greater: int | None = None, + episodes_lesser: int | None = None, + duration_greater: int | None = None, + duration_lesser: int | None = None, + chapters_greater: int | None = None, + chapters_lesser: int | None = None, + volumes_greater: int | None = None, + volumes_lesser: int | None = None, + genre_in: Iterable[str] | None = None, + genre_not_in: Iterable[str] | None = None, + tag_in: Iterable[str] | None = None, + tag_not_in: Iterable[str] | None = None, + tag_category_in: Iterable[str] | None = None, + tag_category_not_in: Iterable[str] | None = None, + licensed_by_in: Iterable[str] | None = None, + licensed_by_id_in: Iterable[int] | None = None, + average_score_not: int | None = None, + average_score_greater: int | None = None, + average_score_lesser: int | None = None, + popularity_not: int | None = None, + popularity_greater: int | None = None, + popularity_lesser: int | None = None, + source_in: Iterable[MediaSource] | None = None, + sort: Iterable[MediaSort] | None = None, ) -> Media: """ Search for media on AniList based on the provided parameters. Parameters ---------- - title : AniListTitle - The string used for searching on AniList. - season : MediaSeason | None, optional - The season the media was initially released in. Default is None. - season_year : AniListYear | None, optional - The season year the media was initially released in. Default is None. - type : MediaType | None, optional - The type of the media; anime or manga. Default is None. - format : MediaFormat | None, optional - The format the media was released in. Default is None. - status : MediaStatus | None, optional - The current releasing status of the media. Default is None. + search : str, optional + Filter by search query + id : int, optional + Filter by the media id + id_mal : int, optional + Filter by the media's MyAnimeList id + start_date : FuzzyDateInt, optional + Filter by the start date of the media + end_date : FuzzyDateInt, optional + Filter by the end date of the media + season : MediaSeason, optional + Filter by the season the media was released in + season_year : int, optional + The year of the season (Winter 2017 would also include December 2016 releases). Requires season argument + type : MediaType, optional + Filter by the media's type + format : MediaFormat, optional + Filter by the media's format + status : MediaStatus, optional + Filter by the media's current release status + episodes : int, optional + Filter by amount of episodes the media has + chapters : int, optional + Filter by the media's episode length + duration : int, optional + Filter by the media's chapter count + volumes : int, optional + Filter by the media's volume count + is_adult : bool, optional + Filter by if the media's intended for 18+ adult audiences + genre : str, optional + Filter by the media's genres + tag : str, optional + Filter by the media's tags + minimum_tag_rank : int, optional + Only apply the tags filter argument to tags above this rank. Default: 18 + tag_category : str, optional + Filter by the media's tags with in a tag category + licensed_by : str, optional + Filter media by sites name with a online streaming or reading license + licensed_by_id : int, optional + Filter media by sites id with a online streaming or reading license + average_score : int, optional + Filter by the media's average score + popularity : int, optional + Filter by the number of users with this media on their list + source : MediaSource, optional + Filter by the source type of the media + country_of_origin : CountryCode | str, optional + Filter by the media's country of origin + is_licensed : bool, optional + If the media is officially licensed or a self-published doujin release + id_not : int, optional + Filter by the media id + id_in : Iterable[int], optional + Filter by the media id + id_not_in : Iterable[int], optional + Filter by the media id + id_mal_not : int, optional + Filter by the media's MyAnimeList id + id_mal_in : Iterable[int], optional + Filter by the media's MyAnimeList id + id_mal_not_in : Iterable[int], optional + Filter by the media's MyAnimeList id + start_date_greater : FuzzyDateInt, optional + Filter by the start date of the media + start_date_lesser : FuzzyDateInt, optional + Filter by the start date of the media + start_date_like : str, optional + Filter by the start date of the media + end_date_greater : FuzzyDateInt, optional + Filter by the end date of the media + end_date_lesser : FuzzyDateInt, optional + Filter by the end date of the media + end_date_like : str, optional + Filter by the end date of the media + format_in : Iterable[MediaFormat], optional + Filter by the media's format + format_not : MediaFormat, optional + Filter by the media's format + format_not_in : Iterable[MediaFormat], optional + Filter by the media's format + status_in : Iterable[MediaStatus], optional + Filter by the media's current release status + status_not : MediaStatus, optional + Filter by the media's current release status + status_not_in : Iterable[MediaStatus], optional + Filter by the media's current release status + episodes_greater : int, optional + Filter by amount of episodes the media has + episodes_lesser : int, optional + Filter by amount of episodes the media has + duration_greater : int, optional + Filter by the media's episode length + duration_lesser : int, optional + Filter by the media's episode length + chapters_greater : int, optional + Filter by the media's chapter count + chapters_lesser : int, optional + Filter by the media's chapter count + volumes_greater : int, optional + Filter by the media's volume count + volumes_lesser : int, optional + Filter by the media's volume count + genre_in : Iterable[str], optional + Filter by the media's genres + genre_not_in : Iterable[str], optional + Filter by the media's genres + tag_in : Iterable[str], optional + Filter by the media's tags + tag_not_in : Iterable[str], optional + Filter by the media's tags + tag_category_in : Iterable[str], optional + Filter by the media's tags with in a tag category + tag_category_not_in : Iterable[str], optional + Filter by the media's tags with in a tag category + licensed_by_in : Iterable[str], optional + Filter media by sites name with a online streaming or reading license + licensed_by_id_in : Iterable[int], optional + Filter media by sites id with a online streaming or reading license + average_score_not : int, optional + Filter by the media's average score + average_score_greater : int, optional + Filter by the media's average score + average_score_lesser : int, optional + Filter by the media's average score + popularity_not : int, optional + Filter by the number of users with this media on their list + popularity_greater : int, optional + Filter by the number of users with this media on their list + popularity_lesser : int, optional + Filter by the number of users with this media on their list + source_in : Iterable[MediaSource], optional + Filter by the source type of the media + sort : Iterable[MediaSort], optional + The order the results will be returned in Raises ------ @@ -262,45 +300,76 @@ async def search( """ return Media.model_validate( - await self._post_process_response( + post_process_response( await self._post_request( - title=title, + id=id, + id_mal=id_mal, + start_date=start_date, + end_date=end_date, season=season, season_year=season_year, type=type, format=format, status=status, - ) - ) - ) - - @validate_call - async def get(self, id: AniListID) -> Media: - """ - Retrieve media information from AniList based on it's ID. - - Parameters - ---------- - id : int - AniList ID of the media as found in the URL: `https://anilist.co/{type}/{id}`. - - Raises - ------ - ValidationError - Invalid input - HTTPStatusError - AniList returned a non 2xx response. - - Returns - ------- - Media - A Media object representing the retrieved media information. - """ - - return Media.model_validate( - await self._post_process_response( - await self._post_request( - id=id, + episodes=episodes, + chapters=chapters, + duration=duration, + volumes=volumes, + is_adult=is_adult, + genre=genre, + tag=tag, + minimum_tag_rank=minimum_tag_rank, + tag_category=tag_category, + licensed_by=licensed_by, + licensed_by_id=licensed_by_id, + average_score=average_score, + popularity=popularity, + source=source, + country_of_origin=country_of_origin, + is_licensed=is_licensed, + search=search, + id_not=id_not, + id_in=id_in, + id_not_in=id_not_in, + id_mal_not=id_mal_not, + id_mal_in=id_mal_in, + id_mal_not_in=id_mal_not_in, + start_date_greater=start_date_greater, + start_date_lesser=start_date_lesser, + start_date_like=start_date_like, + end_date_greater=end_date_greater, + end_date_lesser=end_date_lesser, + end_date_like=end_date_like, + format_in=format_in, + format_not=format_not, + format_not_in=format_not_in, + status_in=status_in, + status_not=status_not, + status_not_in=status_not_in, + episodes_greater=episodes_greater, + episodes_lesser=episodes_lesser, + duration_greater=duration_greater, + duration_lesser=duration_lesser, + chapters_greater=chapters_greater, + chapters_lesser=chapters_lesser, + volumes_greater=volumes_greater, + volumes_lesser=volumes_lesser, + genre_in=genre_in, + genre_not_in=genre_not_in, + tag_in=tag_in, + tag_not_in=tag_not_in, + tag_category_in=tag_category_in, + tag_category_not_in=tag_category_not_in, + licensed_by_in=licensed_by_in, + licensed_by_id_in=licensed_by_id_in, + average_score_not=average_score_not, + average_score_greater=average_score_greater, + average_score_lesser=average_score_lesser, + popularity_not=popularity_not, + popularity_greater=popularity_greater, + popularity_lesser=popularity_lesser, + source_in=source_in, + sort=sort, ) ) ) diff --git a/src/pyanilist/_clients/_sync.py b/src/pyanilist/_clients/_sync.py index 33b2560..05adeee 100644 --- a/src/pyanilist/_clients/_sync.py +++ b/src/pyanilist/_clients/_sync.py @@ -1,22 +1,25 @@ from __future__ import annotations +from collections.abc import Iterable from typing import Any -import httpx +from httpx import Client, HTTPError, Response from pydantic import PositiveInt, validate_call +from pydantic_extra_types.country import CountryAlpha2 as CountryCode from stamina import retry_context -from .._enums import MediaFormat, MediaSeason, MediaStatus, MediaType +from .._enums import MediaFormat, MediaSeason, MediaSort, MediaSource, MediaStatus, MediaType from .._models import Media +from .._parser import post_process_response from .._query import query_string -from .._types import AniListID, AniListTitle, AniListYear, HTTPXClientKwargs -from .._utils import flatten, markdown_formatter, remove_null_fields, sanitize_description, text_formatter +from .._types import FuzzyDateInt +from .._utils import query_variables_constructor class AniList: @validate_call def __init__( - self, api_url: str = "https://graphql.anilist.co", retries: PositiveInt = 5, **kwargs: HTTPXClientKwargs + self, api_url: str = "https://graphql.anilist.co", retries: PositiveInt = 5, **kwargs: Any ) -> None: """ AniList API client. @@ -28,43 +31,22 @@ def __init__( retries : PositiveInt, optional Number of times to retry a failed request before raising an error. Default is 5. Set this to 1 to disable retrying. - kwargs : HTTPXClientKwargs, optional - Keyword arguments to pass to the underlying [httpx.Client()](https://www.python-httpx.org/api/#client) + kwargs : Any, optional + Keyword arguments to pass to the underlying [`httpx.Client`](https://www.python-httpx.org/api/#client) used to make the POST request. """ self.api_url = api_url self.retries = retries self.kwargs = kwargs - def _post_request( - self, - id: AniListID | None = None, - season: MediaSeason | None = None, - season_year: AniListYear | None = None, - type: MediaType | None = None, - format: MediaFormat | None = None, - status: MediaStatus | None = None, - title: AniListTitle | None = None, - ) -> httpx.Response: + def _post_request(self, **kwargs: Any) -> Response: """ Make a POST request to the AniList API. Parameters ---------- - id : AniListID, optional - AniList ID of the media as found in the URL: `https://anilist.co/{type}/{id}`. Default is None. - season : MediaSeason | None, optional - The season the media was initially released in. Default is None. - season_year : AniListYear | None, optional - The season year the media was initially released in. Default is None. - type : MediaType | None, optional - The type of the media; anime or manga. Default is None. - format : MediaFormat | None, optional - The format the media was released in. Default is None. - status : MediaStatus | None, optional - The current releasing status of the media. Default is None. - title : AniListTitle | None, optional - The string used for searching on AniList. Default is None. + kwargs : Any + Anilist query kwargs Raises ------ @@ -78,174 +60,232 @@ def _post_request( The response object from the AniList API. """ - # map params with AniList's - query_variables = dict( - id=id, - season=season, - seasonYear=season_year, - type=type, - format=format, - status=status, - search=title, - ) - payload = { "query": query_string, - "variables": {key: value for key, value in query_variables.items() if value is not None}, + "variables": query_variables_constructor(kwargs), } - for attempt in retry_context(on=httpx.HTTPError, attempts=self.retries): + for attempt in retry_context(on=HTTPError, attempts=self.retries): with attempt: - with httpx.Client(**self.kwargs) as client: + with Client(**self.kwargs) as client: response = client.post(self.api_url, json=payload).raise_for_status() - + return response - @staticmethod - def _process_description(dictionary: dict[str, Any]) -> dict[str, Any]: - """ - Anilist's description field takes a parameter `asHtml: boolean`, effectively - resulting in two differently formatted descriptions. - - Despite what the name implies, `asHtml` being `False` does not gurantee that there will be no `HTML` - tags in the description. - - So we do a bit of post processing: - - Sanitize the resulting descriptions - - Introduce two more formats derived from the original two, i.e, markdown and plain text - - Nest our newly acquired 4 descriptions into a single parent dictionary - - Example: - - This - ```py - { - "defaultDescription": "...", - "htmlDescription": "...", - } - ``` - Turns into this: - ```py - { - "description": { - "default": "...", - "html": "...", - "markdown": "...", - "text": "...", - } - } - ```py - - """ - - default_description = sanitize_description(dictionary.get("defaultDescription")) - html_description = sanitize_description(dictionary.get("htmlDescription")) - markdown_description = markdown_formatter(html_description) - text_description = text_formatter(default_description) - - # Delete the processed keys - dictionary.pop("defaultDescription", None) - dictionary.pop("htmlDescription", None) - - # Nest them inside a parent dictionary - return dict( - default=default_description, - html=html_description, - markdown=markdown_description, - text=text_description, - ) - - def _post_process_response(self, response: httpx.Response) -> dict[str, Any]: - """ - Post-processes the response from AniList API. - - Parameters - ---------- - response : Response - The response object received from AniList API. - - Returns - ------- - dict[str, Any] - Processed dictionary containing media information. - - Notes - ----- - Currently, this function does two things: - 1. Flattening nested structures such as relations, studios, characters, and staff. - 2. Removing null fields to ensure a cleaner output. - """ - - # type hinting for IDE because it doesn't detect it automagically - dictionary: dict[str, Any] - - dictionary = response.json()["data"]["Media"] - - # "Flatten" the nested list of dictionaries of list of dictionaries... blah blah - # into simpler list for more intuitive access - relations = dictionary.get("relations") - dictionary.pop("relations", None) - flattened_relations = flatten(relations, "relationType") - - # same thing here - studios = dictionary.get("studios") - dictionary.pop("studios", None) - flattened_studios = flatten(studios, "isMain") - - # same thing here - characters = dictionary.get("characters") - dictionary.pop("characters", None) - flattened_characters = flatten(characters, "role") - - # same thing here - staff = dictionary.get("staff") - dictionary.pop("staff", None) - flattened_staff = flatten(staff, "role") - - # Process description - dictionary["description"] = self._process_description(dictionary) - - # Process description of every relation - for relation in flattened_relations: - relation["description"] = self._process_description(relation) - - # replace the original - dictionary["relations"] = flattened_relations - dictionary["studios"] = flattened_studios - dictionary["characters"] = flattened_characters - dictionary["staff"] = flattened_staff - - # self explanatory, details on why - # are in the function docstring - return remove_null_fields(dictionary) - @validate_call - def search( + def get( self, - title: AniListTitle, + search: str | None = None, + *, + id: int | None = None, + id_mal: int | None = None, + start_date: FuzzyDateInt | None = None, + end_date: FuzzyDateInt | None = None, season: MediaSeason | None = None, - season_year: AniListYear | None = None, + season_year: int | None = None, type: MediaType | None = None, format: MediaFormat | None = None, status: MediaStatus | None = None, + episodes: int | None = None, + chapters: int | None = None, + duration: int | None = None, + volumes: int | None = None, + is_adult: bool | None = None, + genre: str | None = None, + tag: str | None = None, + minimum_tag_rank: int | None = None, + tag_category: str | None = None, + licensed_by: str | None = None, + licensed_by_id: int | None = None, + average_score: int | None = None, + popularity: int | None = None, + source: MediaSource | None = None, + country_of_origin: CountryCode | str | None = None, + is_licensed: bool | None = None, + id_not: int | None = None, + id_in: Iterable[int] | None = None, + id_not_in: Iterable[int] | None = None, + id_mal_not: int | None = None, + id_mal_in: Iterable[int] | None = None, + id_mal_not_in: Iterable[int] | None = None, + start_date_greater: FuzzyDateInt | None = None, + start_date_lesser: FuzzyDateInt | None = None, + start_date_like: str | None = None, + end_date_greater: FuzzyDateInt | None = None, + end_date_lesser: FuzzyDateInt | None = None, + end_date_like: str | None = None, + format_in: Iterable[MediaFormat] | None = None, + format_not: MediaFormat | None = None, + format_not_in: Iterable[MediaFormat] | None = None, + status_in: Iterable[MediaStatus] | None = None, + status_not: MediaStatus | None = None, + status_not_in: Iterable[MediaStatus] | None = None, + episodes_greater: int | None = None, + episodes_lesser: int | None = None, + duration_greater: int | None = None, + duration_lesser: int | None = None, + chapters_greater: int | None = None, + chapters_lesser: int | None = None, + volumes_greater: int | None = None, + volumes_lesser: int | None = None, + genre_in: Iterable[str] | None = None, + genre_not_in: Iterable[str] | None = None, + tag_in: Iterable[str] | None = None, + tag_not_in: Iterable[str] | None = None, + tag_category_in: Iterable[str] | None = None, + tag_category_not_in: Iterable[str] | None = None, + licensed_by_in: Iterable[str] | None = None, + licensed_by_id_in: Iterable[int] | None = None, + average_score_not: int | None = None, + average_score_greater: int | None = None, + average_score_lesser: int | None = None, + popularity_not: int | None = None, + popularity_greater: int | None = None, + popularity_lesser: int | None = None, + source_in: Iterable[MediaSource] | None = None, + sort: Iterable[MediaSort] | None = None, ) -> Media: """ Search for media on AniList based on the provided parameters. Parameters ---------- - title : AniListTitle - The string used for searching on AniList. - season : MediaSeason | None, optional - The season the media was initially released in. Default is None. - season_year : AniListYear | None, optional - The season year the media was initially released in. Default is None. - type : MediaType | None, optional - The type of the media; anime or manga. Default is None. - format : MediaFormat | None, optional - The format the media was released in. Default is None. - status : MediaStatus | None, optional - The current releasing status of the media. Default is None. + search : str, optional + Filter by search query + id : int, optional + Filter by the media id + id_mal : int, optional + Filter by the media's MyAnimeList id + start_date : FuzzyDateInt, optional + Filter by the start date of the media + end_date : FuzzyDateInt, optional + Filter by the end date of the media + season : MediaSeason, optional + Filter by the season the media was released in + season_year : int, optional + The year of the season (Winter 2017 would also include December 2016 releases). Requires season argument + type : MediaType, optional + Filter by the media's type + format : MediaFormat, optional + Filter by the media's format + status : MediaStatus, optional + Filter by the media's current release status + episodes : int, optional + Filter by amount of episodes the media has + chapters : int, optional + Filter by the media's episode length + duration : int, optional + Filter by the media's chapter count + volumes : int, optional + Filter by the media's volume count + is_adult : bool, optional + Filter by if the media's intended for 18+ adult audiences + genre : str, optional + Filter by the media's genres + tag : str, optional + Filter by the media's tags + minimum_tag_rank : int, optional + Only apply the tags filter argument to tags above this rank. Default: 18 + tag_category : str, optional + Filter by the media's tags with in a tag category + licensed_by : str, optional + Filter media by sites name with a online streaming or reading license + licensed_by_id : int, optional + Filter media by sites id with a online streaming or reading license + average_score : int, optional + Filter by the media's average score + popularity : int, optional + Filter by the number of users with this media on their list + source : MediaSource, optional + Filter by the source type of the media + country_of_origin : CountryCode | str, optional + Filter by the media's country of origin + is_licensed : bool, optional + If the media is officially licensed or a self-published doujin release + id_not : int, optional + Filter by the media id + id_in : Iterable[int], optional + Filter by the media id + id_not_in : Iterable[int], optional + Filter by the media id + id_mal_not : int, optional + Filter by the media's MyAnimeList id + id_mal_in : Iterable[int], optional + Filter by the media's MyAnimeList id + id_mal_not_in : Iterable[int], optional + Filter by the media's MyAnimeList id + start_date_greater : FuzzyDateInt, optional + Filter by the start date of the media + start_date_lesser : FuzzyDateInt, optional + Filter by the start date of the media + start_date_like : str, optional + Filter by the start date of the media + end_date_greater : FuzzyDateInt, optional + Filter by the end date of the media + end_date_lesser : FuzzyDateInt, optional + Filter by the end date of the media + end_date_like : str, optional + Filter by the end date of the media + format_in : Iterable[MediaFormat], optional + Filter by the media's format + format_not : MediaFormat, optional + Filter by the media's format + format_not_in : Iterable[MediaFormat], optional + Filter by the media's format + status_in : Iterable[MediaStatus], optional + Filter by the media's current release status + status_not : MediaStatus, optional + Filter by the media's current release status + status_not_in : Iterable[MediaStatus], optional + Filter by the media's current release status + episodes_greater : int, optional + Filter by amount of episodes the media has + episodes_lesser : int, optional + Filter by amount of episodes the media has + duration_greater : int, optional + Filter by the media's episode length + duration_lesser : int, optional + Filter by the media's episode length + chapters_greater : int, optional + Filter by the media's chapter count + chapters_lesser : int, optional + Filter by the media's chapter count + volumes_greater : int, optional + Filter by the media's volume count + volumes_lesser : int, optional + Filter by the media's volume count + genre_in : Iterable[str], optional + Filter by the media's genres + genre_not_in : Iterable[str], optional + Filter by the media's genres + tag_in : Iterable[str], optional + Filter by the media's tags + tag_not_in : Iterable[str], optional + Filter by the media's tags + tag_category_in : Iterable[str], optional + Filter by the media's tags with in a tag category + tag_category_not_in : Iterable[str], optional + Filter by the media's tags with in a tag category + licensed_by_in : Iterable[str], optional + Filter media by sites name with a online streaming or reading license + licensed_by_id_in : Iterable[int], optional + Filter media by sites id with a online streaming or reading license + average_score_not : int, optional + Filter by the media's average score + average_score_greater : int, optional + Filter by the media's average score + average_score_lesser : int, optional + Filter by the media's average score + popularity_not : int, optional + Filter by the number of users with this media on their list + popularity_greater : int, optional + Filter by the number of users with this media on their list + popularity_lesser : int, optional + Filter by the number of users with this media on their list + source_in : Iterable[MediaSource], optional + Filter by the source type of the media + sort : Iterable[MediaSort], optional + The order the results will be returned in Raises ------ @@ -261,45 +301,76 @@ def search( """ return Media.model_validate( - self._post_process_response( + post_process_response( self._post_request( - title=title, + id=id, + id_mal=id_mal, + start_date=start_date, + end_date=end_date, season=season, season_year=season_year, type=type, format=format, status=status, - ) - ) - ) - - @validate_call - def get(self, id: AniListID) -> Media: - """ - Retrieve media information from AniList based on it's ID. - - Parameters - ---------- - id : int - AniList ID of the media as found in the URL: `https://anilist.co/{type}/{id}`. - - Raises - ------ - ValidationError - Invalid input - HTTPStatusError - AniList returned a non 2xx response. - - Returns - ------- - Media - A Media object representing the retrieved media information. - """ - - return Media.model_validate( - self._post_process_response( - self._post_request( - id=id, + episodes=episodes, + chapters=chapters, + duration=duration, + volumes=volumes, + is_adult=is_adult, + genre=genre, + tag=tag, + minimum_tag_rank=minimum_tag_rank, + tag_category=tag_category, + licensed_by=licensed_by, + licensed_by_id=licensed_by_id, + average_score=average_score, + popularity=popularity, + source=source, + country_of_origin=country_of_origin, + is_licensed=is_licensed, + search=search, + id_not=id_not, + id_in=id_in, + id_not_in=id_not_in, + id_mal_not=id_mal_not, + id_mal_in=id_mal_in, + id_mal_not_in=id_mal_not_in, + start_date_greater=start_date_greater, + start_date_lesser=start_date_lesser, + start_date_like=start_date_like, + end_date_greater=end_date_greater, + end_date_lesser=end_date_lesser, + end_date_like=end_date_like, + format_in=format_in, + format_not=format_not, + format_not_in=format_not_in, + status_in=status_in, + status_not=status_not, + status_not_in=status_not_in, + episodes_greater=episodes_greater, + episodes_lesser=episodes_lesser, + duration_greater=duration_greater, + duration_lesser=duration_lesser, + chapters_greater=chapters_greater, + chapters_lesser=chapters_lesser, + volumes_greater=volumes_greater, + volumes_lesser=volumes_lesser, + genre_in=genre_in, + genre_not_in=genre_not_in, + tag_in=tag_in, + tag_not_in=tag_not_in, + tag_category_in=tag_category_in, + tag_category_not_in=tag_category_not_in, + licensed_by_in=licensed_by_in, + licensed_by_id_in=licensed_by_id_in, + average_score_not=average_score_not, + average_score_greater=average_score_greater, + average_score_lesser=average_score_lesser, + popularity_not=popularity_not, + popularity_greater=popularity_greater, + popularity_lesser=popularity_lesser, + source_in=source_in, + sort=sort, ) ) ) diff --git a/src/pyanilist/_compat.py b/src/pyanilist/_compat.py index 018be54..6365446 100644 --- a/src/pyanilist/_compat.py +++ b/src/pyanilist/_compat.py @@ -1,4 +1,5 @@ """Compatibility module to for older python versions""" +from __future__ import annotations import sys diff --git a/src/pyanilist/_enums.py b/src/pyanilist/_enums.py index 127be97..8f81061 100644 --- a/src/pyanilist/_enums.py +++ b/src/pyanilist/_enums.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from ._compat import StrEnum @@ -15,6 +17,8 @@ def title(self) -> str: # type: ignore "TV_SHORT": "TV Short", "OVA": "OVA", "ONA": "ONA", + "ID": "ID", + "ID_DESC": "ID Desc", } if self.value in exceptions: @@ -237,6 +241,48 @@ class MediaRankType(BaseStrEnum): """Ranking is based on the media's popularity""" +class MediaSort(BaseStrEnum): + """Media sort enums""" + + ID = "ID" + ID_DESC = "ID_DESC" + TITLE_ROMAJI = "TITLE_ROMAJI" + TITLE_ROMAJI_DESC = "TITLE_ROMAJI_DESC" + TITLE_ENGLISH = "TITLE_ENGLISH" + TITLE_ENGLISH_DESC = "TITLE_ENGLISH_DESC" + TITLE_NATIVE = "TITLE_NATIVE" + TITLE_NATIVE_DESC = "TITLE_NATIVE_DESC" + TYPE = "TYPE" + TYPE_DESC = "TYPE_DESC" + FORMAT = "FORMAT" + FORMAT_DESC = "FORMAT_DESC" + START_DATE = "START_DATE" + START_DATE_DESC = "START_DATE_DESC" + END_DATE = "END_DATE" + END_DATE_DESC = "END_DATE_DESC" + SCORE = "SCORE" + SCORE_DESC = "SCORE_DESC" + POPULARITY = "POPULARITY" + POPULARITY_DESC = "POPULARITY_DESC" + TRENDING = "TRENDING" + TRENDING_DESC = "TRENDING_DESC" + EPISODES = "EPISODES" + EPISODES_DESC = "EPISODES_DESC" + DURATION = "DURATION" + DURATION_DESC = "DURATION_DESC" + STATUS = "STATUS" + STATUS_DESC = "STATUS_DESC" + CHAPTERS = "CHAPTERS" + CHAPTERS_DESC = "CHAPTERS_DESC" + VOLUMES = "VOLUMES" + VOLUMES_DESC = "VOLUMES_DESC" + UPDATED_AT = "UPDATED_AT" + UPDATED_AT_DESC = "UPDATED_AT_DESC" + SEARCH_MATCH = "SEARCH_MATCH" + FAVOURITES = "FAVOURITES" + FAVOURITES_DESC = "FAVOURITES_DESC" + + __all__ = [ "CharacterRole", "ExternalLinkType", @@ -244,6 +290,7 @@ class MediaRankType(BaseStrEnum): "MediaRankType", "MediaRelation", "MediaSeason", + "MediaSort", "MediaSource", "MediaStatus", "MediaType", diff --git a/src/pyanilist/_exceptions.py b/src/pyanilist/_exceptions.py deleted file mode 100644 index 2a29f2f..0000000 --- a/src/pyanilist/_exceptions.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -This module simply re-exports Exceptions from -[`httpx`](https://www.python-httpx.org/exceptions/) and -[`pydantic`](https://docs.pydantic.dev/latest/api/pydantic_core/#pydantic_core.ValidationError) for convenience. -""" - -from httpx import ( - CloseError, - ConnectError, - ConnectTimeout, - CookieConflict, - DecodingError, - HTTPError, - HTTPStatusError, - InvalidURL, - LocalProtocolError, - NetworkError, - PoolTimeout, - ProtocolError, - ProxyError, - ReadError, - ReadTimeout, - RemoteProtocolError, - RequestError, - RequestNotRead, - ResponseNotRead, - StreamClosed, - StreamConsumed, - StreamError, - TimeoutException, - TooManyRedirects, - UnsupportedProtocol, - WriteError, - WriteTimeout, -) -from pydantic import ValidationError - -__all__ = [ - "CloseError", - "ConnectError", - "ConnectTimeout", - "CookieConflict", - "DecodingError", - "HTTPError", - "HTTPStatusError", - "InvalidURL", - "LocalProtocolError", - "NetworkError", - "PoolTimeout", - "ProtocolError", - "ProxyError", - "ReadError", - "ReadTimeout", - "RemoteProtocolError", - "RequestError", - "RequestNotRead", - "ResponseNotRead", - "StreamClosed", - "StreamConsumed", - "StreamError", - "TimeoutException", - "TooManyRedirects", - "UnsupportedProtocol", - "WriteError", - "WriteTimeout", - "ValidationError", -] diff --git a/src/pyanilist/_models.py b/src/pyanilist/_models.py index 4f1b0aa..94a0d84 100644 --- a/src/pyanilist/_models.py +++ b/src/pyanilist/_models.py @@ -2,8 +2,10 @@ from datetime import datetime -from pydantic import AliasGenerator, BaseModel, ConfigDict +from pydantic import AliasGenerator, BaseModel, ConfigDict, HttpUrl from pydantic.alias_generators import to_camel +from pydantic_extra_types.color import Color +from pydantic_extra_types.country import CountryAlpha2 as CountryCode from ._enums import ( CharacterRole, @@ -16,7 +18,7 @@ MediaStatus, MediaType, ) -from ._types import Color, CountryCode, HttpUrl, YearsActive +from ._types import FuzzyDateInt, YearsActive class ParentModel(BaseModel): @@ -80,6 +82,20 @@ def iso_format(self) -> str: else: return "" + + def as_int(self) -> FuzzyDateInt: + """ + Return an 8 digit long date integer (YYYYMMDD). + Unknown dates represented by 0. + For example, 2016 is 20160000 and May 1976 is 19760500 + + The result is equivalent to AniList's FuzzyDateInt type. + """ + year = str(self.year).zfill(4) if self.year is not None else "1000" + month = str(self.month).zfill(2) if self.month is not None else "00" + day = str(self.day).zfill(2) if self.day is not None else "00" + + return int(f"{year}{month}{day}") class MediaTrailer(ParentModel): diff --git a/src/pyanilist/_parser.py b/src/pyanilist/_parser.py new file mode 100644 index 0000000..dbe1ec1 --- /dev/null +++ b/src/pyanilist/_parser.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ._utils import flatten, markdown_formatter, remove_null_fields, sanitize_description, text_formatter + +if TYPE_CHECKING: + from httpx import Response + +def process_description(dictionary: dict[str, Any]) -> dict[str, Any]: + """ + Anilist's description field takes a parameter `asHtml: boolean`, effectively + resulting in two differently formatted descriptions. + + Despite what the name implies, `asHtml` being `False` does not gurantee that there will be no `HTML` + tags in the description. + + So we do a bit of post processing: + - Sanitize the resulting descriptions + - Introduce two more formats derived from the original two, i.e, markdown and plain text + - Nest our newly acquired 4 descriptions into a single parent dictionary + + Example: + + This + ```py + { + "defaultDescription": "...", + "htmlDescription": "...", + } + ``` + Turns into this: + ```py + { + "description": { + "default": "...", + "html": "...", + "markdown": "...", + "text": "...", + } + } + ```py + + """ + + default_description = sanitize_description(dictionary.get("defaultDescription")) + html_description = sanitize_description(dictionary.get("htmlDescription")) + markdown_description = markdown_formatter(html_description) + text_description = text_formatter(default_description) + + # Delete the processed keys + dictionary.pop("defaultDescription", None) + dictionary.pop("htmlDescription", None) + + # Nest them inside a parent dictionary + return dict( + default=default_description, + html=html_description, + markdown=markdown_description, + text=text_description, + ) + +def post_process_response(response: Response) -> dict[str, Any]: + """ + Post-processes the response from AniList API. + + Parameters + ---------- + response : Response + The response object received from AniList API. + + Returns + ------- + dict[str, Any] + Processed dictionary containing media information. + + Notes + ----- + Currently, this function does two things: + 1. Flattening nested structures such as relations, studios, characters, and staff. + 2. Removing null fields to ensure a cleaner output. + """ + + # type hinting for IDE because it doesn't detect it automagically + dictionary: dict[str, Any] + + dictionary = response.json()["data"]["Media"] + + # "Flatten" the nested list of dictionaries of list of dictionaries... blah blah + # into simpler list for more intuitive access + relations = dictionary.get("relations") + dictionary.pop("relations", None) + flattened_relations = flatten(relations, "relationType") + + # same thing here + studios = dictionary.get("studios") + dictionary.pop("studios", None) + flattened_studios = flatten(studios, "isMain") + + # same thing here + characters = dictionary.get("characters") + dictionary.pop("characters", None) + flattened_characters = flatten(characters, "role") + + # same thing here + staff = dictionary.get("staff") + dictionary.pop("staff", None) + flattened_staff = flatten(staff, "role") + + # Process description + dictionary["description"] = process_description(dictionary) + + # Process description of every relation + for relation in flattened_relations: + relation["description"] = process_description(relation) + + # replace the original + dictionary["relations"] = flattened_relations + dictionary["studios"] = flattened_studios + dictionary["characters"] = flattened_characters + dictionary["staff"] = flattened_staff + + # self explanatory, details on why + # are in the function docstring + return remove_null_fields(dictionary) \ No newline at end of file diff --git a/src/pyanilist/_query.py b/src/pyanilist/_query.py index e95595b..cc21b8c 100644 --- a/src/pyanilist/_query.py +++ b/src/pyanilist/_query.py @@ -1,6 +1,146 @@ query_string = """ -query ($id: Int, $season: MediaSeason, $seasonYear: Int, $type: MediaType, $format: MediaFormat, $status: MediaStatus, $search: String) { - Media(id: $id, season: $season, seasonYear: $seasonYear, type: $type, format: $format, status: $status, search: $search) { +query ( + $id: Int + $idMal: Int + $startDate: FuzzyDateInt + $endDate: FuzzyDateInt + $season: MediaSeason + $seasonYear: Int + $type: MediaType + $format: MediaFormat + $status: MediaStatus + $episodes: Int + $chapters: Int + $duration: Int + $volumes: Int + $isAdult: Boolean + $genre: String + $tag: String + $minimumTagRank: Int + $tagCategory: String + # $onList: Boolean + $licensedBy: String + $licensedById: Int + $averageScore: Int + $popularity: Int + $source: MediaSource + $countryOfOrigin: CountryCode + $isLicensed: Boolean + $search: String + $id_not: Int + $id_in: [Int] + $id_not_in: [Int] + $idMal_not: Int + $idMal_in: [Int] + $idMal_not_in: [Int] + $startDate_greater: FuzzyDateInt + $startDate_lesser: FuzzyDateInt + $startDate_like: String + $endDate_greater: FuzzyDateInt + $endDate_lesser: FuzzyDateInt + $endDate_like: String + $format_in: [MediaFormat] + $format_not: MediaFormat + $format_not_in: [MediaFormat] + $status_in: [MediaStatus] + $status_not: MediaStatus + $status_not_in: [MediaStatus] + $episodes_greater: Int + $episodes_lesser: Int + $duration_greater: Int + $duration_lesser: Int + $chapters_greater: Int + $chapters_lesser: Int + $volumes_greater: Int + $volumes_lesser: Int + $genre_in: [String] + $genre_not_in: [String] + $tag_in: [String] + $tag_not_in: [String] + $tagCategory_in: [String] + $tagCategory_not_in: [String] + $licensedBy_in: [String] + $licensedById_in: [Int] + $averageScore_not: Int + $averageScore_greater: Int + $averageScore_lesser: Int + $popularity_not: Int + $popularity_greater: Int + $popularity_lesser: Int + $source_in: [MediaSource] + $sort: [MediaSort] +) { + Media( + id: $id + idMal: $idMal + startDate: $startDate + endDate: $endDate + season: $season + seasonYear: $seasonYear + type: $type + format: $format + status: $status + episodes: $episodes + chapters: $chapters + duration: $duration + volumes: $volumes + isAdult: $isAdult + genre: $genre + tag: $tag + minimumTagRank: $minimumTagRank + tagCategory: $tagCategory + # $onList: $onList + licensedBy: $licensedBy + licensedById: $licensedById + averageScore: $averageScore + popularity: $popularity + source: $source + countryOfOrigin: $countryOfOrigin + isLicensed: $isLicensed + search: $search + id_not: $id_not + id_in: $id_in + id_not_in: $id_not_in + idMal_not: $idMal_not + idMal_in: $idMal_in + idMal_not_in: $idMal_not_in + startDate_greater: $startDate_greater + startDate_lesser: $startDate_lesser + startDate_like: $startDate_like + endDate_greater: $endDate_greater + endDate_lesser: $endDate_lesser + endDate_like: $endDate_like + format_in: $format_in + format_not: $format_not + format_not_in: $format_not_in + status_in: $status_in + status_not: $status_not + status_not_in: $status_not_in + episodes_greater: $episodes_greater + episodes_lesser: $episodes_lesser + duration_greater: $duration_greater + duration_lesser: $duration_lesser + chapters_greater: $chapters_greater + chapters_lesser: $chapters_lesser + volumes_greater: $volumes_greater + volumes_lesser: $volumes_lesser + genre_in: $genre_in + genre_not_in: $genre_not_in + tag_in: $tag_in + tag_not_in: $tag_not_in + tagCategory_in: $tagCategory_in + tagCategory_not_in: $tagCategory_not_in + licensedBy_in: $licensedBy_in + licensedById_in: $licensedById_in + averageScore_not: $averageScore_not + averageScore_greater: $averageScore_greater + averageScore_lesser: $averageScore_lesser + popularity_not: $popularity_not + popularity_greater: $popularity_greater + popularity_lesser: $popularity_lesser + source_in: $source_in + sort: $sort + ) { id idMal title { @@ -287,5 +427,4 @@ siteUrl } } - """ diff --git a/src/pyanilist/_types.py b/src/pyanilist/_types.py index bbf2402..e627b28 100644 --- a/src/pyanilist/_types.py +++ b/src/pyanilist/_types.py @@ -7,12 +7,10 @@ from __future__ import annotations -from typing import Annotated, Any +from typing import Annotated -from pydantic import Field, HttpUrl -from pydantic_extra_types.color import Color -from pydantic_extra_types.country import CountryAlpha2 as CountryCode -from typing_extensions import NamedTuple, TypeAlias +from pydantic import Field +from typing_extensions import NamedTuple class YearsActive(NamedTuple): @@ -25,22 +23,16 @@ class YearsActive(NamedTuple): end_year: int | None = None -AniListID = Annotated[int, Field(gt=0, description="AniList ID as found in the URL: https://anilist.co/{type}/{id}")] -AniListYear = Annotated[int, Field(ge=1000, description="Release Year")] -AniListTitle = Annotated[str, Field(min_length=1, description="Title of the media")] - -HTTPXClientKwargs: TypeAlias = Any -"""Simple TypeAlias to refer to `httpx.Client()` kwargs""" +FuzzyDateInt = Annotated[ + int, + Field( + ge=10000000, + le=99999999, + description="8 digit long date integer (YYYYMMDD). Unknown dates represented by 0. E.g. 2016: 20160000, May 1976: 19760500", + ), +] -HTTPXAsyncClientKwargs: TypeAlias = Any -"""Simple TypeAlias to refer to `httpx.AsyncClient()` kwargs""" __all__ = [ - "AniListID", - "AniListTitle", - "AniListYear", - "Color", - "CountryCode", - "HttpUrl", "YearsActive", ] diff --git a/src/pyanilist/_utils.py b/src/pyanilist/_utils.py index 4881e5d..b61080d 100644 --- a/src/pyanilist/_utils.py +++ b/src/pyanilist/_utils.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from collections.abc import Iterable from typing import Any import nh3 @@ -99,3 +100,90 @@ def remove_null_fields(dictionary: dict[str, Any]) -> dict[str, Any]: `None`, empty list, or empty dictionary. """ return remap(dictionary, lambda path, key, value: value not in [None, {}, []]) # type: ignore + +def query_variables_constructor(vars: dict[str, Any]) -> dict[str, Any]: + """ + Anilist doesn't stick to a single casing. + Most of it is camelCase but then there's some made up stuff in there too. + So can do nothing but create a mapping from snake_case to anilistCase + """ + casing = { + "id": "id", + "id_mal": "idMal", + "start_date": "startDate", + "end_date": "endDate", + "season": "season", + "season_year": "seasonYear", + "type": "type", + "format": "format", + "status": "status", + "episodes": "episodes", + "chapters": "chapters", + "duration": "duration", + "volumes": "volumes", + "is_adult": "isAdult", + "genre": "genre", + "tag": "tag", + "minimum_tag_rank": "minimumTagRank", + "tag_category": "tagCategory", + "licensed_by": "licensedBy", + "licensed_by_id": "licensedById", + "average_score": "averageScore", + "popularity": "popularity", + "source": "source", + "country_of_origin": "countryOfOrigin", + "is_licensed": "isLicensed", + "search": "search", + "id_not": "id_not", + "id_in": "id_in", + "id_not_in": "id_not_in", + "id_mal_not": "idMal_not", + "id_mal_in": "idMal_in", + "id_mal_not_in": "idMal_not_in", + "start_date_greater": "startDate_greater", + "start_date_lesser": "startDate_lesser", + "start_date_like": "startDate_like", + "end_date_greater": "endDate_greater", + "end_date_lesser": "endDate_lesser", + "end_date_like": "endDate_like", + "format_in": "format_in", + "format_not": "format_not", + "format_not_in": "format_not_in", + "status_in": "status_in", + "status_not": "status_not", + "status_not_in": "status_not_in", + "episodes_greater": "episodes_greater", + "episodes_lesser": "episodes_lesser", + "duration_greater": "duration_greater", + "duration_lesser": "duration_lesser", + "chapters_greater": "chapters_greater", + "chapters_lesser": "chapters_lesser", + "volumes_greater": "volumes_greater", + "volumes_lesser": "volumes_lesser", + "genre_in": "genre_in", + "genre_not_in": "genre_not_in", + "tag_in": "tag_in", + "tag_not_in": "tag_not_in", + "tag_category_in": "tagCategory_in", + "tag_category_not_in": "tagCategory_not_in", + "licensed_by_in": "licensedBy_in", + "licensed_by_id_in": "licensedById_in", + "average_score_not": "averageScore_not", + "average_score_greater": "averageScore_greater", + "average_score_lesser": "averageScore_lesser", + "popularity_not": "popularity_not", + "popularity_greater": "popularity_greater", + "popularity_lesser": "popularity_lesser", + "source_in": "source_in", + "sort": "sort", + } + + query_vars = {} + + for key, value in vars.items(): + if value is not None: + if isinstance(value, Iterable) and not isinstance(value, str): + value = tuple(value) + query_vars[casing[key]] = value + + return query_vars \ No newline at end of file diff --git a/src/pyanilist/_version.py b/src/pyanilist/_version.py index c44892e..2fefceb 100644 --- a/src/pyanilist/_version.py +++ b/src/pyanilist/_version.py @@ -1,22 +1,18 @@ +from __future__ import annotations + from typing_extensions import NamedTuple from ._compat import metadata class Version(NamedTuple): - """Version tuple based on SemVer""" - major: int - """Major version number""" minor: int - """Minor version number""" - patch: int - """Patch version number""" - + micro: int def _get_version() -> str: """ - Get the version of juicenet + Get the version of pyanilist """ try: return metadata.version("pyanilist") diff --git a/tests/test_anilist.py b/tests/test_anilist.py index 5593c24..10ded63 100644 --- a/tests/test_anilist.py +++ b/tests/test_anilist.py @@ -1,7 +1,6 @@ from pyanilist import ( AniList, CharacterRole, - HttpUrl, MediaFormat, MediaSeason, MediaSource, @@ -13,7 +12,7 @@ def test_anilist_anime() -> None: - media = AniList().search("Attack on titan", type=MediaType.ANIME) + media = AniList().get("Attack on titan", type=MediaType.ANIME) assert media.title.romaji == "Shingeki no Kyojin" assert media.start_date.year == 2013 assert media.start_date.iso_format() == "2013-04-07" @@ -47,11 +46,11 @@ def test_anilist_anime() -> None: ("Daizen Komatsuda", "Storyboard (ep 23)"), ("You Moriyama", "Key Animation (OP1)"), ] - assert media.site_url == HttpUrl("https://anilist.co/anime/16498") + assert media.site_url.__str__() == "https://anilist.co/anime/16498" def test_anilist_manga() -> None: - media = AniList().search("Attack on titan", type=MediaType.MANGA) + media = AniList().get("Attack on titan", type=MediaType.MANGA) assert media.title.romaji == "Shingeki no Kyojin" assert media.start_date.year == 2009 assert media.start_date.iso_format() == "2009-09-09" @@ -73,11 +72,11 @@ def test_anilist_manga() -> None: ("Shintarou Kawakubo", "Editing"), ("Yifeng Zhang", "Translator (Chinese)"), ] - assert media.site_url == HttpUrl("https://anilist.co/manga/53390") + assert media.site_url.__str__() == "https://anilist.co/manga/53390" def test_anilist_with_some_constraints() -> None: - media = AniList().search( + media = AniList().get( "violet evergarden", type=MediaType.MANGA, format=MediaFormat.NOVEL, status=MediaStatus.FINISHED ) assert media.title.romaji == "Violet Evergarden" @@ -90,11 +89,11 @@ def test_anilist_with_some_constraints() -> None: ("Akiko Takase", "Illustration"), ("Kana Akatsuki", "Story"), ] - assert media.site_url == HttpUrl("https://anilist.co/manga/97298") + assert media.site_url.__str__() == "https://anilist.co/manga/97298" def test_anilist_with_all_constraints() -> None: - media = AniList().search( + media = AniList().get( "My Hero Academia", season=MediaSeason.SPRING, season_year=2016, @@ -135,11 +134,11 @@ def test_anilist_with_all_constraints() -> None: ("Katsuyuki Kodera", "Storyboard (eps 5, 9, 13)"), ("Kenji Nagasaki", "Storyboard (OP, ED, eps 1, 2)"), ] - assert media.site_url == HttpUrl("https://anilist.co/anime/21459") + assert media.site_url.__str__() =="https://anilist.co/anime/21459" def test_anilist_id() -> None: - media = AniList().get(16498) + media = AniList().get(id=16498) assert media.title.romaji == "Shingeki no Kyojin" assert media.start_date.year == 2013 assert media.source is MediaSource.MANGA @@ -173,11 +172,11 @@ def test_anilist_id() -> None: ("Daizen Komatsuda", "Storyboard (ep 23)"), ("You Moriyama", "Key Animation (OP1)"), ] - assert media.site_url == HttpUrl("https://anilist.co/anime/16498") + assert media.site_url.__str__() == "https://anilist.co/anime/16498" def test_anilist_description() -> None: - media = AniList().get(106794) + media = AniList().get(id=106794) assert media.title.english == "Bloom Into You Anthology" assert media.start_date.year == 2018 assert media.source is MediaSource.MANGA @@ -190,11 +189,11 @@ def test_anilist_description() -> None: assert media.relations[0].description.html == BloomIntoYouDescriptions.HTML assert media.relations[0].description.markdown == BloomIntoYouDescriptions.MARKDOWN assert media.relations[0].description.text == BloomIntoYouDescriptions.TEXT - assert media.site_url == HttpUrl("https://anilist.co/manga/106794") + assert media.site_url.__str__() == "https://anilist.co/manga/106794" def test_anilist_characters() -> None: - media = AniList().get(20954) + media = AniList().get(id=20954) assert media.title.english == "A Silent Voice" assert media.start_date.year == 2016 assert media.source is MediaSource.MANGA @@ -203,4 +202,4 @@ def test_anilist_characters() -> None: "Shouya Ishida", "Shouko Nishimiya", ] - assert media.site_url == HttpUrl("https://anilist.co/anime/20954") + assert media.site_url.__str__() == "https://anilist.co/anime/20954" diff --git a/tests/test_async_anilist.py b/tests/test_async_anilist.py index 0724fcd..f5ecae0 100644 --- a/tests/test_async_anilist.py +++ b/tests/test_async_anilist.py @@ -1,7 +1,6 @@ from pyanilist import ( AsyncAniList, CharacterRole, - HttpUrl, MediaFormat, MediaSeason, MediaSource, @@ -13,7 +12,7 @@ async def test_anilist_anime() -> None: - media = await AsyncAniList().search("Attack on titan", type=MediaType.ANIME) + media = await AsyncAniList().get("Attack on titan", type=MediaType.ANIME) assert media.title.romaji == "Shingeki no Kyojin" assert media.start_date.year == 2013 assert media.start_date.iso_format() == "2013-04-07" @@ -47,11 +46,11 @@ async def test_anilist_anime() -> None: ("Daizen Komatsuda", "Storyboard (ep 23)"), ("You Moriyama", "Key Animation (OP1)"), ] - assert media.site_url == HttpUrl("https://anilist.co/anime/16498") + assert media.site_url.__str__() == "https://anilist.co/anime/16498" async def test_anilist_manga() -> None: - media = await AsyncAniList().search("Attack on titan", type=MediaType.MANGA) + media = await AsyncAniList().get("Attack on titan", type=MediaType.MANGA) assert media.title.romaji == "Shingeki no Kyojin" assert media.start_date.year == 2009 assert media.start_date.iso_format() == "2009-09-09" @@ -73,11 +72,11 @@ async def test_anilist_manga() -> None: ("Shintarou Kawakubo", "Editing"), ("Yifeng Zhang", "Translator (Chinese)"), ] - assert media.site_url == HttpUrl("https://anilist.co/manga/53390") + assert media.site_url.__str__() == "https://anilist.co/manga/53390" async def test_anilist_with_some_constraints() -> None: - media = await AsyncAniList().search( + media = await AsyncAniList().get( "violet evergarden", type=MediaType.MANGA, format=MediaFormat.NOVEL, status=MediaStatus.FINISHED ) assert media.title.romaji == "Violet Evergarden" @@ -90,11 +89,11 @@ async def test_anilist_with_some_constraints() -> None: ("Akiko Takase", "Illustration"), ("Kana Akatsuki", "Story"), ] - assert media.site_url == HttpUrl("https://anilist.co/manga/97298") + assert media.site_url.__str__() == "https://anilist.co/manga/97298" async def test_anilist_with_all_constraints() -> None: - media = await AsyncAniList().search( + media = await AsyncAniList().get( "My Hero Academia", season=MediaSeason.SPRING, season_year=2016, @@ -135,11 +134,11 @@ async def test_anilist_with_all_constraints() -> None: ("Katsuyuki Kodera", "Storyboard (eps 5, 9, 13)"), ("Kenji Nagasaki", "Storyboard (OP, ED, eps 1, 2)"), ] - assert media.site_url == HttpUrl("https://anilist.co/anime/21459") + assert media.site_url.__str__() == "https://anilist.co/anime/21459" async def test_anilist_id() -> None: - media = await AsyncAniList().get(16498) + media = await AsyncAniList().get(id=16498) assert media.title.romaji == "Shingeki no Kyojin" assert media.start_date.year == 2013 assert media.source is MediaSource.MANGA @@ -173,11 +172,11 @@ async def test_anilist_id() -> None: ("Daizen Komatsuda", "Storyboard (ep 23)"), ("You Moriyama", "Key Animation (OP1)"), ] - assert media.site_url == HttpUrl("https://anilist.co/anime/16498") + assert media.site_url.__str__() == "https://anilist.co/anime/16498" async def test_anilist_description() -> None: - media = await AsyncAniList().get(106794) + media = await AsyncAniList().get(id=106794) assert media.title.english == "Bloom Into You Anthology" assert media.start_date.year == 2018 assert media.source is MediaSource.MANGA @@ -190,11 +189,11 @@ async def test_anilist_description() -> None: assert media.relations[0].description.html == BloomIntoYouDescriptions.HTML assert media.relations[0].description.markdown == BloomIntoYouDescriptions.MARKDOWN assert media.relations[0].description.text == BloomIntoYouDescriptions.TEXT - assert media.site_url == HttpUrl("https://anilist.co/manga/106794") + assert media.site_url.__str__() == "https://anilist.co/manga/106794" async def test_anilist_characters() -> None: - media = await AsyncAniList().get(20954) + media = await AsyncAniList().get(id=20954) assert media.title.english == "A Silent Voice" assert media.start_date.year == 2016 assert media.source is MediaSource.MANGA @@ -203,4 +202,4 @@ async def test_anilist_characters() -> None: "Shouya Ishida", "Shouko Nishimiya", ] - assert media.site_url == HttpUrl("https://anilist.co/anime/20954") + assert media.site_url.__str__() == "https://anilist.co/anime/20954" diff --git a/tests/test_async_exceptions.py b/tests/test_async_exceptions.py index 31077ae..2666008 100644 --- a/tests/test_async_exceptions.py +++ b/tests/test_async_exceptions.py @@ -10,14 +10,14 @@ async def test_anilist_bad_search_combo() -> None: with pytest.raises(HTTPStatusError): - await anilist.search("Attack on titan", season_year=1999) + await anilist.get("Attack on titan", season_year=1999) async def test_anilist_wrong_input_types() -> None: with pytest.raises(ValidationError): - await anilist.search(123456789, season_year="hello", type=True) # type: ignore + await anilist.get(id=123456789, season_year="hello", type=True) # type: ignore async def test_anilist_bad_id() -> None: with pytest.raises(HTTPStatusError): - await anilist.get(9999999999) + await anilist.get(id=9999999999) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 3ddfdf2..b80a665 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -10,14 +10,14 @@ def test_anilist_bad_search_combo() -> None: with pytest.raises(HTTPStatusError): - anilist.search("Attack on titan", season_year=1999) + anilist.get("Attack on titan", season_year=1999) def test_anilist_wrong_input_types() -> None: with pytest.raises(ValidationError): - anilist.search(123456789, season_year="hello", type=True) # type: ignore + anilist.get(id=123456789, season_year="hello", type=True) # type: ignore def test_anilist_bad_id() -> None: with pytest.raises(HTTPStatusError): - anilist.get(9999999999) + anilist.get(id=9999999999) diff --git a/tests/test_models.py b/tests/test_models.py index ec21a07..d8f95ac 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -5,4 +5,9 @@ def test_fuzzy_date() -> None: assert FuzzyDate(year=2023, month=1, day=4).iso_format() == "2023-01-04" assert FuzzyDate(year=2023, month=1).iso_format() == "2023-01" assert FuzzyDate(year=2023).iso_format() == "2023" - assert FuzzyDate().iso_format() == "" + + assert FuzzyDate(year=2023, month=1, day=4).as_int() == 20230104 + assert FuzzyDate(year=2023, month=1).as_int() == 20230100 + assert FuzzyDate(year=2023).as_int() == 20230000 + + assert FuzzyDate().iso_format() == "" \ No newline at end of file