From d10adee231e4245796209413be5e65b17012a826 Mon Sep 17 00:00:00 2001 From: amine Date: Fri, 18 Jul 2025 10:13:36 +0200 Subject: [PATCH 01/14] feat: add modern python stack --- .pre-commit-config.yaml | 35 +++++ .python-version | 1 + pyproject.toml | 27 ++++ uv.lock | 284 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 347 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 .python-version create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2853709 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,35 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.3.0 + hooks: + - id: check-yaml + args: [--unsafe] + - id: end-of-file-fixer + - id: trailing-whitespace +- repo: local + hooks: + - id: ruff fix + name: Check and fix Python linting with ruff + entry: ruff check + types: [python] + language: system + - id: ruff format + name: Autoformat Python files with ruff + entry: ruff format + types: [python] + language: system + - id: pyright + name: Type checking with pyright + entry: pyright + types: [python] + language: system +- repo: https://github.com/jendrikseipp/vulture + rev: 'v2.14' + hooks: + - id: vulture + name: Check unused Python code +- repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.5.14 + hooks: + - id: uv-lock + name: Check if uv.lock is up to date diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ecbaa85 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "python-stack" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "requests>=2.32.4", +] + +[dependency-groups] +dev = [ + "pre-commit>=4.2.0", + "pyright>=1.1.403", + "ruff>=0.12.4", +] + +[tool.pyright] +reportIncompatibleMethodOverride = true +typeCheckingMode = "strict" + +[tool.ruff] +src = [".", "retroachievements"] +line-length = 88 + +[tool.ruff.lint] +extend-select = ["I"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..636b769 --- /dev/null +++ b/uv.lock @@ -0,0 +1,284 @@ +version = 1 +revision = 2 +requires-python = ">=3.11" + +[[package]] +name = "certifi" +version = "2025.7.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/76/52c535bcebe74590f296d6c77c86dabf761c41980e1347a2422e4aa2ae41/certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995", size = 163981, upload-time = "2025-07-14T03:29:28.449Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722, upload-time = "2025-07-14T03:29:26.863Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" }, + { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" }, + { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" }, + { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" }, + { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" }, + { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" }, + { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" }, + { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, +] + +[[package]] +name = "identify" +version = "2.6.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", size = 99254, upload-time = "2025-05-23T20:37:53.3Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145, upload-time = "2025-05-23T20:37:51.495Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.403" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/f6/35f885264ff08c960b23d1542038d8da86971c5d8c955cfab195a4f672d7/pyright-1.1.403.tar.gz", hash = "sha256:3ab69b9f41c67fb5bbb4d7a36243256f0d549ed3608678d381d5f51863921104", size = 3913526, upload-time = "2025-07-09T07:15:52.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/b6/b04e5c2f41a5ccad74a1a4759da41adb20b4bc9d59a5e08d29ba60084d07/pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3", size = 5684504, upload-time = "2025-07-09T07:15:50.958Z" }, +] + +[[package]] +name = "python-stack" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pre-commit" }, + { name = "pyright" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "requests", specifier = ">=2.32.4" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pre-commit", specifier = ">=4.2.0" }, + { name = "pyright", specifier = ">=1.1.403" }, + { name = "ruff", specifier = ">=0.12.4" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, +] + +[[package]] +name = "ruff" +version = "0.12.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/ce/8d7dbedede481245b489b769d27e2934730791a9a82765cb94566c6e6abd/ruff-0.12.4.tar.gz", hash = "sha256:13efa16df6c6eeb7d0f091abae50f58e9522f3843edb40d56ad52a5a4a4b6873", size = 5131435, upload-time = "2025-07-17T17:27:19.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/9f/517bc5f61bad205b7f36684ffa5415c013862dee02f55f38a217bdbe7aa4/ruff-0.12.4-py3-none-linux_armv6l.whl", hash = "sha256:cb0d261dac457ab939aeb247e804125a5d521b21adf27e721895b0d3f83a0d0a", size = 10188824, upload-time = "2025-07-17T17:26:31.412Z" }, + { url = "https://files.pythonhosted.org/packages/28/83/691baae5a11fbbde91df01c565c650fd17b0eabed259e8b7563de17c6529/ruff-0.12.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:55c0f4ca9769408d9b9bac530c30d3e66490bd2beb2d3dae3e4128a1f05c7442", size = 10884521, upload-time = "2025-07-17T17:26:35.084Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8d/756d780ff4076e6dd035d058fa220345f8c458391f7edfb1c10731eedc75/ruff-0.12.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a8224cc3722c9ad9044da7f89c4c1ec452aef2cfe3904365025dd2f51daeae0e", size = 10277653, upload-time = "2025-07-17T17:26:37.897Z" }, + { url = "https://files.pythonhosted.org/packages/8d/97/8eeee0f48ece153206dce730fc9e0e0ca54fd7f261bb3d99c0a4343a1892/ruff-0.12.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9949d01d64fa3672449a51ddb5d7548b33e130240ad418884ee6efa7a229586", size = 10485993, upload-time = "2025-07-17T17:26:40.68Z" }, + { url = "https://files.pythonhosted.org/packages/49/b8/22a43d23a1f68df9b88f952616c8508ea6ce4ed4f15353b8168c48b2d7e7/ruff-0.12.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:be0593c69df9ad1465e8a2d10e3defd111fdb62dcd5be23ae2c06da77e8fcffb", size = 10022824, upload-time = "2025-07-17T17:26:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/cd/70/37c234c220366993e8cffcbd6cadbf332bfc848cbd6f45b02bade17e0149/ruff-0.12.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7dea966bcb55d4ecc4cc3270bccb6f87a337326c9dcd3c07d5b97000dbff41c", size = 11524414, upload-time = "2025-07-17T17:26:46.219Z" }, + { url = "https://files.pythonhosted.org/packages/14/77/c30f9964f481b5e0e29dd6a1fae1f769ac3fd468eb76fdd5661936edd262/ruff-0.12.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afcfa3ab5ab5dd0e1c39bf286d829e042a15e966b3726eea79528e2e24d8371a", size = 12419216, upload-time = "2025-07-17T17:26:48.883Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/af7fe0a4202dce4ef62c5e33fecbed07f0178f5b4dd9c0d2fcff5ab4a47c/ruff-0.12.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c057ce464b1413c926cdb203a0f858cd52f3e73dcb3270a3318d1630f6395bb3", size = 11976756, upload-time = "2025-07-17T17:26:51.754Z" }, + { url = "https://files.pythonhosted.org/packages/09/d1/33fb1fc00e20a939c305dbe2f80df7c28ba9193f7a85470b982815a2dc6a/ruff-0.12.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64b90d1122dc2713330350626b10d60818930819623abbb56535c6466cce045", size = 11020019, upload-time = "2025-07-17T17:26:54.265Z" }, + { url = "https://files.pythonhosted.org/packages/64/f4/e3cd7f7bda646526f09693e2e02bd83d85fff8a8222c52cf9681c0d30843/ruff-0.12.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2abc48f3d9667fdc74022380b5c745873499ff827393a636f7a59da1515e7c57", size = 11277890, upload-time = "2025-07-17T17:26:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d0/69a85fb8b94501ff1a4f95b7591505e8983f38823da6941eb5b6badb1e3a/ruff-0.12.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2b2449dc0c138d877d629bea151bee8c0ae3b8e9c43f5fcaafcd0c0d0726b184", size = 10348539, upload-time = "2025-07-17T17:26:59.381Z" }, + { url = "https://files.pythonhosted.org/packages/16/a0/91372d1cb1678f7d42d4893b88c252b01ff1dffcad09ae0c51aa2542275f/ruff-0.12.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:56e45bb11f625db55f9b70477062e6a1a04d53628eda7784dce6e0f55fd549eb", size = 10009579, upload-time = "2025-07-17T17:27:02.462Z" }, + { url = "https://files.pythonhosted.org/packages/23/1b/c4a833e3114d2cc0f677e58f1df6c3b20f62328dbfa710b87a1636a5e8eb/ruff-0.12.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:478fccdb82ca148a98a9ff43658944f7ab5ec41c3c49d77cd99d44da019371a1", size = 10942982, upload-time = "2025-07-17T17:27:05.343Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ce/ce85e445cf0a5dd8842f2f0c6f0018eedb164a92bdf3eda51984ffd4d989/ruff-0.12.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0fc426bec2e4e5f4c4f182b9d2ce6a75c85ba9bcdbe5c6f2a74fcb8df437df4b", size = 11343331, upload-time = "2025-07-17T17:27:08.652Z" }, + { url = "https://files.pythonhosted.org/packages/35/cf/441b7fc58368455233cfb5b77206c849b6dfb48b23de532adcc2e50ccc06/ruff-0.12.4-py3-none-win32.whl", hash = "sha256:4de27977827893cdfb1211d42d84bc180fceb7b72471104671c59be37041cf93", size = 10267904, upload-time = "2025-07-17T17:27:11.814Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7e/20af4a0df5e1299e7368d5ea4350412226afb03d95507faae94c80f00afd/ruff-0.12.4-py3-none-win_amd64.whl", hash = "sha256:fe0b9e9eb23736b453143d72d2ceca5db323963330d5b7859d60d101147d461a", size = 11209038, upload-time = "2025-07-17T17:27:14.417Z" }, + { url = "https://files.pythonhosted.org/packages/11/02/8857d0dfb8f44ef299a5dfd898f673edefb71e3b533b3b9d2db4c832dd13/ruff-0.12.4-py3-none-win_arm64.whl", hash = "sha256:0618ec4442a83ab545e5b71202a5c0ed7791e8471435b94e655b570a5031a98e", size = 10469336, upload-time = "2025-07-17T17:27:16.913Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316, upload-time = "2025-05-08T17:58:23.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982, upload-time = "2025-05-08T17:58:21.15Z" }, +] From 235c2d27a65fa3abfe5192b3760ac0a63c7f3903 Mon Sep 17 00:00:00 2001 From: amine Date: Fri, 18 Jul 2025 10:20:34 +0200 Subject: [PATCH 02/14] fix: project name --- pyproject.toml | 2 +- uv.lock | 50 +++++++++++++++++++++++++------------------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ecbaa85..c4b792f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "python-stack" +name = "api-python" version = "0.1.0" description = "Add your description here" readme = "README.md" diff --git a/uv.lock b/uv.lock index 636b769..b1400ee 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,31 @@ version = 1 revision = 2 requires-python = ">=3.11" +[[package]] +name = "api-python" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pre-commit" }, + { name = "pyright" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "requests", specifier = ">=2.32.4" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pre-commit", specifier = ">=4.2.0" }, + { name = "pyright", specifier = ">=1.1.403" }, + { name = "ruff", specifier = ">=0.12.4" }, +] + [[package]] name = "certifi" version = "2025.7.14" @@ -151,31 +176,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/b6/b04e5c2f41a5ccad74a1a4759da41adb20b4bc9d59a5e08d29ba60084d07/pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3", size = 5684504, upload-time = "2025-07-09T07:15:50.958Z" }, ] -[[package]] -name = "python-stack" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "requests" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pre-commit" }, - { name = "pyright" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [{ name = "requests", specifier = ">=2.32.4" }] - -[package.metadata.requires-dev] -dev = [ - { name = "pre-commit", specifier = ">=4.2.0" }, - { name = "pyright", specifier = ">=1.1.403" }, - { name = "ruff", specifier = ">=0.12.4" }, -] - [[package]] name = "pyyaml" version = "6.0.2" From af7064357b11db021e3d87d9b29079258269e9ee Mon Sep 17 00:00:00 2001 From: amine Date: Fri, 18 Jul 2025 11:06:09 +0200 Subject: [PATCH 03/14] fix: (WIP) typing problesm --- retroachievements/client.py | 657 ++++++++++++++++++++++++++++++++++-- retroachievements/types.py | 200 +++++++++++ 2 files changed, 829 insertions(+), 28 deletions(-) create mode 100644 retroachievements/types.py diff --git a/retroachievements/client.py b/retroachievements/client.py index c2e2718..eab92de 100644 --- a/retroachievements/client.py +++ b/retroachievements/client.py @@ -1,36 +1,75 @@ -import requests as request -from retroachievements import __version__ +import datetime +import warnings + +import requests +from retroachievements import __version__ +from retroachievements.types import ( + DatedUserAchievementResponse, + GetAchievementCountResponse, + GetAchievementDistributionResponse, + GetGameExtendedResponse, + GetGameHashesResponse, + GetGameRankAndScoreResponse, + GetUserAwardsResponse, + GetUserCompletionProgressResponseEntity, + GetUserRecentAchievementsResponse, + UserProfileResponse, +) _BASE_URL = "https://retroachievements.org/API/" class RAClient: """ - Main class for accessing the RetroAhievements Web API + Main class for accessing the RetroAchievements Web API """ - headers = { - "User-Agent": "RetroAchievements-api-python/" + __version__} + headers = {"User-Agent": "RetroAchievements-api-python/" + __version__} - def __init__(self, username, api_key): - self.username = username + def __init__(self, api_key: str): self.api_key = api_key - def url_params(self, params=None): + def url_params(self, params: dict[str, str | int] | None = None): """ Inserts the auth and query params into the request """ if params is None: params = {} - params.update({"z": self.username, "y": self.api_key}) + params.update({"y": self.api_key}) return params + def is_valid_game_csv(self, value: str | int) -> bool: + """ + Validates if the given value is a valid game CSV string or integer. + + Example of valid game CSV: + 12345, + "12345" + "12345, 67890" + "123, 456, 789" + + Example of invalid game ID: + "123a" + "123,,456" + "123, " + """ + if isinstance(value, int): + return True + parts = value.split(",") + return all(part.strip().isdigit() for part in parts if part) + # URL construction - def _call_api(self, endpoint=None, params=None, timeout=30, headers=None): + def _call_api( + self, + endpoint: str | None = None, + params: dict[str, str | int] | None = None, + timeout: int = 30, + headers: dict[str, str] | None = None, + ): if endpoint is None: - endpoint = {} - req = request.get( + endpoint = "" + req = requests.get( f"{_BASE_URL}{endpoint}", params=self.url_params(params), timeout=timeout, @@ -40,30 +79,287 @@ def _call_api(self, endpoint=None, params=None, timeout=30, headers=None): # User endpoints + def get_user_profile(self, user: str, ulid: str) -> UserProfileResponse: + """ + Get a user's profile information + + Params: + u: Username to query + i: ULID to query + """ + result = self._call_api( + "API_GetUserProfile.php?", {"u": user, "i": ulid} + ).json() + return result + + def get_user_recent_achievements( + self, user: str, minutes: int = 60 + ) -> GetUserRecentAchievementsResponse: + """ + Get a user's recent achievements + + Params: + u: Username or ULID to query + m: Minutes to look back, default = 60 + """ + result = self._call_api( + "API_GetUserRecentAchievements.php?", {"u": user, "m": minutes} + ).json() + return result + + def get_user_achievements_earned_between( + self, user: str, start: int, end: int + ) -> DatedUserAchievementResponse: + """ + Get a user's achievements in a range + + Params: + u: Username or ULID to query + f: Epoch timestamp. Time range start. + t: Epoch timestamp. Time range end. + """ + result = self._call_api( + "API_GetAchievementsEarnedBetween.php?", {"u": user, "s": start, "e": end} + ).json() + return result + + def get_user_achievements_earned_on_day( + self, user: str, date: str + ) -> DatedUserAchievementResponse: + """ + Get a user's achievements earned on a specific day + + Params: + u: Username or ULID to query + d: Date in YYYY-MM-DD format, default = now + """ + result = self._call_api( + "API_GetAchievementsEarnedOnDay.php?", {"u": user, "d": date} + ).json() + return result + + def get_game_info_and_user_progress( + self, user: str, game: int, awards: int = 0 + ) -> dict: + """ + Get a user's progress in a game, including game metadata + + Params: + u: Username or ULID to query + g: Game ID to query + a: If set to 1 also return the user's awards, default = 0 + """ + if awards not in [0, 1]: + raise ValueError("Invalid awards value. Must be 0 or 1.") + result = self._call_api( + "API_GetGameInfoAndUserProgress.php?", {"u": user, "g": game, "a": awards} + ).json() + return result + + def get_user_completion_progress( + self, user: str, count: int = 100, offset: int = 0 + ) -> GetUserCompletionProgressResponseEntity: + """ + Get a user's completion progress in games + + Params: + u: Username or ULID to query + c: Count, the number of records to return (default = 100, max = 500) + o: Offset, the number of entries to skip (default = 0) + """ + result = self._call_api( + "API_GetUserCompletionProgress.php?", {"u": user, "c": count, "o": offset} + ).json() + return result + + def get_user_awards(self, user: str) -> GetUserAwardsResponse: + """ + Get a user's awards + + Params: + u: Username or ULID to query + """ + result = self._call_api("API_GetUserAwards.php?", {"u": user}).json() + return result + + def get_user_claims(self, user: str) -> dict: + """ + Get a user's claims + + Params: + u: Username or ULID to query + """ + result = self._call_api("API_GetUserClaims.php?", {"u": user}).json() + return result + + def get_user_game_rank_and_score(self, user: str, game: int) -> dict: + """ + Get a user's rank and score in a game + + Params: + u: Username or ULID to query + g: Game ID to query + """ + result = self._call_api( + "API_GetUserGameRankAndScore.php?", {"u": user, "g": game} + ).json() + return result + def get_user_points(self, user: str) -> dict: """ Get a user's total hardcore and softcore points Params: - u: Username to query + u: Username or ULID to query """ result = self._call_api("API_GetUserPoints.php?", {"u": user}).json() return result - def get_user_summary(self, user: str, - recent_games=0, - recent_cheevos=10) -> dict: + def get_user_progress(self, user: str, game: str | int) -> dict: + """ + Get a user's progress in a game + + Params: + u: Username or ULID to query + i: Game ID to query + + Information: + Unless you are explicitly wanting summary progress details for specific game IDs, get_user_completion_progress will almost certainly be better-suited for your use case. + """ + warnings.warn( + "Unless you are explicitly wanting summary progress details for specific game IDs, get_user_completion_progress will almost certainly be better-suited for your use case.", + Warning, + stacklevel=2, + ) + if not self.is_valid_game_csv(game): + raise ValueError("Invalid game ID or CSV format") + + result = self._call_api( + "API_GetUserProgress.php?", {"u": user, "i": game} + ).json() + return result + + def get_user_recently_played_games( + self, user: str, count: int = 10, offset: int = 0 + ) -> dict: + """ + Get a user's recently played games + + Params: + u: Username or ULID to query + c: Count, the number of records to return (default = 10, max = 50) + o: Offset, the number of entries to skip (default = 0) + """ + result = self._call_api( + "API_GetUserRecentlyPlayedGames.php?", {"u": user, "c": count, "o": offset} + ).json() + return result + + def get_user_summary( + self, user: str, games: int = 0, achievements: int = 10 + ) -> dict: """ Get a user's exhaustive profile metadata Params: - u: Username to query + u: Username or ULID to query g: Number of recent games to fetch, default = 0 a: Number of recent achievements to fetch, default = 10 + + Information: + This endpoint is known to be slow, and often results in over-fetching. For basic user profile information, try the get_user_profile endpoint. For user completion and game progress information, try the get_user_completion_progress endpoint. + + Recent achievements are pulled from recent games, so if you ask for 1 game and 10 achievements, and the user has only earned 8 achievements in the most recent game, you'll only get 8 recent achievements back. Similarly, with the default of 0 recent games, no recent achievements will be returned. """ + warnings.warn( + "This endpoint is known to be slow, and often results in over-fetching. For basic user profile information, try the get_user_profile endpoint. For user completion and game progress information, try the get_user_completion_progress endpoint." + "Recent achievements are pulled from recent games, so if you ask for 1 game and 10 achievements, and the user has only earned 8 achievements in the most recent game, you'll only get 8 recent achievements back. Similarly, with the default of 0 recent games, no recent achievements will be returned.", + Warning, + stacklevel=2, + ) result = self._call_api( "API_GetUserSummary.php?", - {"u": user, "g": recent_games, "a": recent_cheevos}, + {"u": user, "g": games, "a": achievements}, + ).json() + return result + + def get_user_completed_games(self, user: str) -> dict: + """ + Get a user's completed games + + Params: + u: Username or ULID to query + + Information: + This endpoint is considered "legacy". The get_user_completion_progress endpoint will almost always be a better fit for your use case. + """ + warnings.warn( + "This endpoint is considered 'legacy'. The get_user_completion_progress endpoint will almost always be a better fit for your use case.", + DeprecationWarning, + stacklevel=2, + ) + result = self._call_api("API_GetUserCompletedGames.php?", {"u": user}).json() + return result + + def get_user_want_to_play_list( + self, user: str, count: int = 100, offset: int = 0 + ) -> dict: + """ + Get a user's 'Want to Play' list + + Params: + u: Username or ULID to query + c: Count, the number of records to return (default = 100, max = 500) + o: Offset, the number of entries to skip (default = 0) + """ + result = self._call_api( + "API_GetUserWantToPlayList.php?", {"u": user, "c": count, "o": offset} + ).json() + return result + + def get_users_i_follow(self, user: str, count: int = 100, offset: int = 0) -> dict: + """ + Get a list of users that the specified user follows + + Params: + u: Username or ULID to query + c: Count, the number of records to return (default = 100, max = 500) + o: Offset, the number of entries to skip (default = 0) + """ + result = self._call_api( + "API_GetUsersIFollow.php?", {"u": user, "c": count, "o": offset} + ).json() + return result + + def get_users_following_me( + self, user: str, count: int = 100, offset: int = 0 + ) -> dict: + """ + Get a list of users that follow the specified user + + Params: + u: Username or ULID to query + c: Count, the number of records to return (default = 100, max = 500) + o: Offset, the number of entries to skip (default = 0) + """ + result = self._call_api( + "API_GetUsersFollowingMe.php?", {"u": user, "c": count, "o": offset} + ).json() + return result + + def get_user_set_requests(self, user: str, list_type: int = 0) -> dict: + """ + Get a user's set requests + + Params: + u: Username or ULID to query + t: List type: 0 for active requests, 1 for all requests, default = 0 + """ + if list_type not in [0, 1]: + raise ValueError("Invalid list type. Must be 0 or 1.") + + result = self._call_api( + "API_GetUserSetRequests.php?", {"u": user, "t": list_type} ).json() return result @@ -79,36 +375,129 @@ def get_game(self, game: int) -> dict: result = self._call_api("API_GetGame.php?", {"i": game}).json() return result - def get_game_extended(self, game: int) -> dict: + def get_game_extended(self, game: int, focus: int = 3) -> GetGameExtendedResponse: """ Get extended metadata about a game Params: i: The game ID to query + f: Set to 3 for Official achievements, 5 to see Unofficial / Demoted achievements, default = 3 """ - result = self._call_api("API_GetGameExtended.php?", {"i": game}).json() + if focus not in [3, 5]: + raise ValueError("Invalid set type selected. Must be 3 or 5.") + result = self._call_api( + "API_GetGameExtended.php?", {"i": game, "f": focus} + ).json() + return result + + def get_game_hashes(self, game: int) -> GetGameHashesResponse: + """ + Get the hashes for a game + + Params: + i: The game ID to query + """ + result = self._call_api("API_GetGameHashes.php?", {"i": game}).json() return result - def get_achievement_count(self, game: int) -> dict: + def get_achievement_count(self, game: int) -> GetAchievementCountResponse: """ Get the list of achievement ID's for a game Params: i: The game ID to query """ - result = self._call_api( - "API_GetAchievementCount.php?", {"i": game}).json() + result = self._call_api("API_GetAchievementCount.php?", {"i": game}).json() return result - def get_achievement_distribution(self, game: int) -> dict: + def get_achievement_distribution( + self, game: int, achievement_type: int = 0, focus: int = 3 + ) -> GetAchievementDistributionResponse: """ Get how many players have unlocked how many achievements for a game Params: i: The game ID to query + h: Set to 1 to only query hardcore unlocks, 0 to query all unlocks, default = 0 + f: Set to 3 for Official achievements, 5 for Unofficial / Demoted achievements, default = 3 + """ + if achievement_type not in [0, 1]: + raise ValueError("Invalid achievement type. Must be 0 or 1.") + if focus not in [3, 5]: + raise ValueError("Invalid set type selected. Must be 3 or 5.") + result = self._call_api( + "API_GetAchievementDistribution.php?", + {"i": game, "h": achievement_type, "f": focus}, + ).json() + return result + + def get_game_rank_and_score( + self, game: int, list_type: int = 0 + ) -> GetGameRankAndScoreResponse: + """ + Get the rank and score for a game + + Params: + g: The game ID to query + t: Set to 0 for Latest Masters, 1 for High Scores, default = 0 + """ + if list_type not in [0, 1]: + raise ValueError("Invalid list type. Must be 0 or 1.") + result = self._call_api( + "API_GetGameRankAndScore.php?", {"g": game, "t": list_type} + ).json() + return result + + # Leaderboard Endpoints + + def get_game_leaderboards( + self, game: int, count: int = 100, offset: int = 0 + ) -> dict: + """ + Get the leaderboards for a game + + Params: + i: The game ID to query + c: Count, the number of records to return (default = 100, max = 500) + o: Offset, the number of entries to skip (default = 0) + """ + result = self._call_api( + "API_GetGameLeaderboards.php?", {"i": game, "c": count, "o": offset} + ).json() + return result + + def get_leaderboard_entries( + self, leaderboard: int, count: int = 100, offset: int = 0 + ) -> dict: + """ + Get the entries of a leaderboard + + Params: + i: The leaderboard ID to query + c: Count, the number of records to return (default = 100, max = 500) + o: Offset, the number of entries to skip (default = 0) """ result = self._call_api( - "API_GetAchievementDistribution.php?", {"i": game} + "API_GetLeaderboardEntries.php?", + {"i": leaderboard, "c": count, "o": offset}, + ).json() + return result + + def get_user_game_leaderboards( + self, game: int, user: str, count: int = 200, offset: int = 0 + ) -> dict: + """ + Get a user's leaderboard entries for a game + + Params: + i: Game ID to query + u: Username or ULID to query + c: Count, the number of records to return (default = 200, max = 500) + o: Offset, the number of entries to skip (default = 0) + """ + result = self._call_api( + "API_GetUserGameLeaderboards.php?", + {"i": game, "u": user, "c": count, "o": offset}, ).json() return result @@ -124,7 +513,9 @@ def get_console_ids(self) -> list: result = self._call_api("API_GetConsoleIDs.php?", {}).json() return result - def get_game_list(self, system: int, has_cheevos=0, hashes=0) -> dict: + def get_game_list( + self, system: int, has_achievements: int = 0, hashes: int = 0 + ) -> dict: """ Get the complete list of games for a console @@ -133,8 +524,218 @@ def get_game_list(self, system: int, has_cheevos=0, hashes=0) -> dict: f: If 1, only returns games that have achievements (default = 0) h: If 1, also return the supported hashes for games (default = 0) """ + if has_achievements not in [0, 1]: + raise ValueError("Invalid has_cheevos value. Must be 0 or 1.") + if hashes not in [0, 1]: + raise ValueError("Invalid hashes value. Must be 0 or 1.") + result = self._call_api( + "API_GetGameList.php?", {"i": system, "f": has_achievements, "h": hashes} + ).json() + return result + + # Achievement Endpoints + + def get_achievement_unlocks( + self, achievement: int, count: int = 50, offset: int = 0 + ) -> dict: + """ + Get the unlocks for an achievement + + Params: + a: The achievement ID to query + c: Count, the number of records to return (default = 50, max = 500) + o: Offset, the number of entries to skip (default = 0) + """ + result = self._call_api( + "API_GetAchievementUnlocks.php?", + {"a": achievement, "c": count, "o": offset}, + ).json() + return result + + # Comment Endpoints + + def get_comments( + self, + game: int, + target: int, + count: int = 100, + offset: int = 0, + sort: str = "submitted", + ) -> dict: + """ + Get comments for a game or achievement + + Params: + i: The game ID to query + t: The target ID (game = 1, achievement = 2, user/ulid = 3) + c: Count, the number of records to return (default = 100, max = 500) + o: Offset, the number of entries to skip (default = 0) + sort: Sort order, submitted = ascending, -submitted = descending, default = 'submitted' + """ + if target not in [1, 2, 3]: + raise ValueError( + "Invalid target type. Must be 1 (game), 2 (achievement), or 3 (user/ulid)." + ) + if sort not in ["submitted", "-submitted"]: + raise ValueError("Invalid sort order. Must be 'submitted' or '-submitted'.") + result = self._call_api( + "API_GetComments.php?", + {"i": game, "t": target, "c": count, "o": offset, "sort": sort}, + ).json() + return result + + # Feed Endpoints + def get_recent_game_awards( + self, + date: str | None = None, + offset: int = 0, + count: int = 25, + kind: str | None = None, + ) -> dict: + """ + Get recent game awards + + Params: + d: Date in YYYY-MM-DD format, default = now + o: Offset, the number of entries to skip (default = 0) + c: Count, the number of records to return (default = 25, max = 100) + k: Type of award to filter by (optional), possible values are 'beaten-softcore', 'beaten-hardcore', 'completed' and 'mastered'. If not specified, all awards will be returned. + """ + if date is None: + date = datetime.date.today().strftime("%Y-%m-%d") + if kind is not None and kind not in [ + "beaten-softcore", + "beaten-hardcore", + "completed", + "mastered", + ]: + raise ValueError( + "Invalid kind value. Must be one of 'beaten-softcore', 'beaten-hardcore', 'completed', or 'mastered'." + ) result = self._call_api( - "API_GetGameList.php?", { - "i": system, "f": has_cheevos, "h": hashes} + "API_GetRecentGameAwards.php?", + {"d": date, "o": offset, "c": count, "k": kind}, ).json() return result + + def get_active_claims(self) -> dict: + """ + Get the list of active active claims (1000 max) + + Params: + None + """ + result = self._call_api("API_GetActiveClaims.php?", {}).json() + return result + + def get_inactive_claims(self, kind: int = 1) -> dict: + """ + Get the list of inactive claims, inactive claims are claims that have been completed, dropped or expired. + + Params: + k: Kind of claim to return, 1 (completed), 2 (dropped), 3 (expired), default = 1 + """ + if kind not in [1, 2, 3]: + raise ValueError( + "Invalid kind value. Must be 1 (completed), 2 (dropped) or 3 (expired)." + ) + result = self._call_api("API_GetInactiveClaims.php?", {"k": kind}).json() + return result + + def get_top_ten_users(self) -> dict: + """ + Get the top ten users on the site + + Params: + None + """ + result = self._call_api("API_GetTopTenUsers.php?", {}).json() + return result + + # Event Endpoints + + def get_achievement_of_the_week(self) -> dict: + """ + Get the achievement of the week + + Params: + None + """ + result = self._call_api("API_GetAchievementOfTheWeek.php?", {}).json() + return result + + # Ticket Endpoints + + def get_ticket_data(self, ticket_id: int) -> dict: + """ + Get the data for a specific ticket + + Params: + i: The ticket ID to query + """ + result = self._call_api("API_GetTicketData.php?", {"i": ticket_id}).json() + return result + + def get_most_ticketed_games(self, focus: int = 1) -> dict: + """ + Get the most ticketed games + + Params: + f: Must be set to 1. + """ + result = self._call_api("API_GetTicketData.php?", {"f": focus}).json() + return result + + def get_most_recent_tickets(self, count: int = 10, offset: int = 0) -> dict: + """ + Get the most recent tickets + + Params: + c: Count, the number of records to return (default = 10, max = 100) + o: Offset, the number of entries to skip (default = 0) + """ + result = self._call_api( + "API_GetTicketData.php?", {"c": count, "o": offset} + ).json() + return result + + def get_game_ticket_stats(self, game: int, focus: int = 3, depth: int = 0) -> dict: + """ + Get the ticket stats for a game + + Params: + g: The game ID to query + f: Focus, 3 for official tickets, 5 for unofficial tickets, default = 3 + d: Depth, 0 for basic stats, 1 for deep ticket metadata in the responses Tickets array, default = 0 + """ + if focus not in [3, 5]: + raise ValueError( + "Invalid focus value. Must be 3 (official) or 5 (unofficial)." + ) + result = self._call_api( + "API_GetTicketData.php?", {"g": game, "f": focus, "d": depth} + ).json() + return result + + def get_developer_ticket_stats(self, username: str, ulid: str) -> dict: + """ + Get the ticket stats for a developer + + Params: + u: Username or ULID to query + i: ULID to query + """ + result = self._call_api( + "API_GetTicketData.php?", {"u": username, "i": ulid} + ).json() + return result + + def get_achievement_ticket_stats(self, achievement: int) -> dict: + """ + Get the ticket stats for an achievement + + Params: + a: The achievement ID to query + """ + result = self._call_api("API_GetTicketData.php?", {"a": achievement}).json() + return result diff --git a/retroachievements/types.py b/retroachievements/types.py new file mode 100644 index 0000000..f358935 --- /dev/null +++ b/retroachievements/types.py @@ -0,0 +1,200 @@ +from enum import Enum +from typing import Literal, TypedDict + +AchievementType = Literal["progression", "win_condition", "missable"] | None +AwardKind = Literal["beaten-softcore", "beaten-hardcore", "completed", "mastered"] +AwardType = Literal[ + "Achievement Points Yield", + "Achievement Unlocks Yield", + "Certified Legend", + "Game Beaten", + "Invalid or deprecated award type", + "Mastery/Completion", + "Patreon Supporter", +] + + +class UserProfileResponse(TypedDict): + User: str + UserPic: str + MemberSince: str + RichPresenceMsg: str + LastGameID: int + ContribCount: int + ContribYield: int + TotalPoints: int + TotalSoftcorePoints: int + TotalTruePoints: int + Permissions: int + Untracked: int + ID: int + UserWallActive: int + Motto: str + + +class GetUserRecentAchievementsEntity(TypedDict): + Date: str + HardcoreMode: int # 0 or 1 + AchievementID: int + Title: str + Description: str + BadgeName: str + Points: int + Author: str + GameTitle: str + GameIcon: str + GameID: int + ConsoleName: str + BadgeURL: str + GameURL: str + + +GetUserRecentAchievementsResponse = list[GetUserRecentAchievementsEntity] + + +class DatedUserAchievementResponseEntity(TypedDict): + Date: str + HardcoreMode: str + AchievementID: str + Title: str + Description: str + BadgeName: str + Points: str + Author: str + GameTitle: str + GameIcon: str + GameID: str + ConsoleName: str + CumulScore: int + BadgeURL: str + GameURL: str + Type: AchievementType + + +DatedUserAchievementResponse = list[DatedUserAchievementResponseEntity] + + +class GetUserCompletionProgressResponseEntity(TypedDict): + GameID: int + Title: str + ImageIcon: str + ConsoleID: int + ConsoleName: str + MaxPossible: int + NumAwarded: int + NumAwardedHardcore: int + MostRecentAwardedDate: str | None + HighestAwardKind: AwardKind | None + HighestAwardDate: str | None + + +class GetUserCompletionProgressResponse(TypedDict): + Count: int + Total: int + Results: list[GetUserCompletionProgressResponseEntity] + + +class GameExtendedClaimType(Enum): + Primary = "0" + Collaboration = "1" + + +class GameExtendedRawAchievementEntity(TypedDict): + ID: str + NumAwarded: str + NumAwardedHardcore: str + Title: str + Description: str + Points: str + TrueRatio: str + Author: str + DateModified: str + DateCreated: str + BadgeName: str + DisplayOrder: str + MemAddr: str + + +class GameExtendedRawClaimEntity(TypedDict): + User: str + SetType: str + ClaimType: GameExtendedClaimType + Created: str + Expiration: str + + +class GetGameExtendedResponse(TypedDict): + ID: int + Title: str + ConsoleID: int + ForumTopicID: int + Flags: int + ImageIcon: str + ImageTitle: str + ImageIngame: str + ImageBoxArt: str + Publisher: str + Developer: str + Genre: str + Released: str + IsFinal: bool + ConsoleName: str + RichPresencePatch: str + NumAchievements: int + NumDistinctPlayersCasual: str + NumDistinctPlayersHardcore: str + Claims: list[GameExtendedRawClaimEntity] + Achievements: dict[int, GameExtendedRawAchievementEntity] | list[int] + + +class GameHashResult(TypedDict): + MD5: str + Name: str + Labels: list[str] + PatchUrl: str + + +class GetGameHashesResponse(TypedDict): + Results: list[GameHashResult] + + +class GetAchievementCountResponse(TypedDict): + GameID: int + AchievementIDs: list[int] + + +GetAchievementDistributionResponse = dict[str, int] + + +class RawGameRankAndScoreEntity(TypedDict): + User: str + TotalScore: str + LastAward: str + Rank: int + + +GetGameRankAndScoreResponse = list[RawGameRankAndScoreEntity] + + +class GetUserAwardsEntity(TypedDict): + AwardedAt: str + AwardType: AwardType + AwardData: int + AwardDataExtra: int + DisplayOrder: int + Title: str + ConsoleName: str + Flags: int | None + ImageIcon: str + + +class GetUserAwardsResponse(TypedDict): + TotalAwardsCount: int + HiddenAwardsCount: int + MasteryAwardsCount: int + CompletionAwardsCount: int + BeatenHardcoreAwardsCount: int + BeatenSoftcoreAwardsCount: int + EventAwardsCount: int + SiteAwardsCount: int + VisibleUserAwards: list[GetUserAwardsEntity] From 5368d4d644074892eb9ab566fe542c62a142261c Mon Sep 17 00:00:00 2001 From: amine Date: Sun, 20 Jul 2025 18:59:24 +0200 Subject: [PATCH 04/14] fix: add more endpoint response types --- retroachievements/client.py | 21 +++++--- retroachievements/types.py | 101 ++++++++++++++++++++++++++++++++++-- 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/retroachievements/client.py b/retroachievements/client.py index eab92de..e330e35 100644 --- a/retroachievements/client.py +++ b/retroachievements/client.py @@ -10,8 +10,13 @@ GetAchievementDistributionResponse, GetGameExtendedResponse, GetGameHashesResponse, + GetGameInfoAndUserProgressResponse, GetGameRankAndScoreResponse, + GetRecentGameAwardsResponse, + GetSetClaimsResponse, + GetTopTenUsersResponse, GetUserAwardsResponse, + GetUserClaimsResponse, GetUserCompletionProgressResponseEntity, GetUserRecentAchievementsResponse, UserProfileResponse, @@ -30,7 +35,7 @@ class RAClient: def __init__(self, api_key: str): self.api_key = api_key - def url_params(self, params: dict[str, str | int] | None = None): + def url_params(self, params: dict[str, str | int | None] | None = None): """ Inserts the auth and query params into the request """ @@ -63,7 +68,7 @@ def is_valid_game_csv(self, value: str | int) -> bool: def _call_api( self, endpoint: str | None = None, - params: dict[str, str | int] | None = None, + params: dict[str, str | int | None] | None = None, timeout: int = 30, headers: dict[str, str] | None = None, ): @@ -140,7 +145,7 @@ def get_user_achievements_earned_on_day( def get_game_info_and_user_progress( self, user: str, game: int, awards: int = 0 - ) -> dict: + ) -> GetGameInfoAndUserProgressResponse: """ Get a user's progress in a game, including game metadata @@ -182,7 +187,7 @@ def get_user_awards(self, user: str) -> GetUserAwardsResponse: result = self._call_api("API_GetUserAwards.php?", {"u": user}).json() return result - def get_user_claims(self, user: str) -> dict: + def get_user_claims(self, user: str) -> GetUserClaimsResponse: """ Get a user's claims @@ -591,7 +596,7 @@ def get_recent_game_awards( offset: int = 0, count: int = 25, kind: str | None = None, - ) -> dict: + ) -> GetRecentGameAwardsResponse: """ Get recent game awards @@ -618,7 +623,7 @@ def get_recent_game_awards( ).json() return result - def get_active_claims(self) -> dict: + def get_active_claims(self) -> GetSetClaimsResponse: """ Get the list of active active claims (1000 max) @@ -628,7 +633,7 @@ def get_active_claims(self) -> dict: result = self._call_api("API_GetActiveClaims.php?", {}).json() return result - def get_inactive_claims(self, kind: int = 1) -> dict: + def get_inactive_claims(self, kind: int = 1) -> GetSetClaimsResponse: """ Get the list of inactive claims, inactive claims are claims that have been completed, dropped or expired. @@ -642,7 +647,7 @@ def get_inactive_claims(self, kind: int = 1) -> dict: result = self._call_api("API_GetInactiveClaims.php?", {"k": kind}).json() return result - def get_top_ten_users(self) -> dict: + def get_top_ten_users(self) -> GetTopTenUsersResponse: """ Get the top ten users on the site diff --git a/retroachievements/types.py b/retroachievements/types.py index f358935..7ac9ccb 100644 --- a/retroachievements/types.py +++ b/retroachievements/types.py @@ -2,7 +2,7 @@ from typing import Literal, TypedDict AchievementType = Literal["progression", "win_condition", "missable"] | None -AwardKind = Literal["beaten-softcore", "beaten-hardcore", "completed", "mastered"] +AwardKindType = Literal["beaten-softcore", "beaten-hardcore", "completed", "mastered"] AwardType = Literal[ "Achievement Points Yield", "Achievement Unlocks Yield", @@ -84,7 +84,7 @@ class GetUserCompletionProgressResponseEntity(TypedDict): NumAwarded: int NumAwardedHardcore: int MostRecentAwardedDate: str | None - HighestAwardKind: AwardKind | None + HighestAwardKind: AwardKindType | None HighestAwardDate: str | None @@ -115,6 +115,13 @@ class GameExtendedRawAchievementEntity(TypedDict): MemAddr: str +class GameExtendedRawAchievementEntityWithUserProgress( + GameExtendedRawAchievementEntity +): + DateEarned: str + DateEarnedHardcore: str + + class GameExtendedRawClaimEntity(TypedDict): User: str SetType: str @@ -123,7 +130,7 @@ class GameExtendedRawClaimEntity(TypedDict): Expiration: str -class GetGameExtendedResponse(TypedDict): +class GetGameExtendedResponseWithoutClaims(TypedDict): ID: int Title: str ConsoleID: int @@ -143,6 +150,9 @@ class GetGameExtendedResponse(TypedDict): NumAchievements: int NumDistinctPlayersCasual: str NumDistinctPlayersHardcore: str + + +class GetGameExtendedResponse(GetGameExtendedResponseWithoutClaims): Claims: list[GameExtendedRawClaimEntity] Achievements: dict[int, GameExtendedRawAchievementEntity] | list[int] @@ -198,3 +208,88 @@ class GetUserAwardsResponse(TypedDict): EventAwardsCount: int SiteAwardsCount: int VisibleUserAwards: list[GetUserAwardsEntity] + + +class GetRecentGameAwardsEntity(TypedDict): + User: str + AwardKind: AwardKindType + AwardDate: str + GameID: int + GameTitle: str + ConsoleID: int + ConsoleName: str + + +class GetRecentGameAwardsResponse(TypedDict): + Count: int + Total: int + Results: list[GetRecentGameAwardsEntity] + + +class SetClaimResponseEntity(TypedDict): + ID: int + User: str + GameID: int + GameTitle: str + GameIcon: str + ConsoleName: str + ConsoleID: int + ClaimType: int + SetType: int + Status: int + Extension: int + Special: int + Created: str + DoneTime: str + Updated: str + MinutesLeft: int + UserIsJrDev: Literal[0, 1] + + +GetSetClaimsResponse = list[SetClaimResponseEntity] + + +TopTenUsersResponseEntity = TypedDict( + "TopTenUsersResponseEntity", + { + "1": str, # Username + "2": str, # Total points earned by the user + "3": str, # Total ratio (white) points earned by the user + }, +) + + +GetTopTenUsersResponse = list[TopTenUsersResponseEntity] + + +class GetGameInfoAndUserProgressResponse(GetGameExtendedResponseWithoutClaims): + Achievements: dict[int, GameExtendedRawAchievementEntityWithUserProgress] + + NumAwardedToUser: int + NumAwardedToUserHardcore: int + UserCompletion: str + UserCompletionHardcore: str + + HighestAwardKind: AwardKindType | None + HighestAwardDate: str | None + + +class GetUserClaimsResponseEntity(TypedDict): + ID: str + User: str + GameID: str + GameTitle: str + GameIcon: str + ConsoleName: str + ClaimType: str + SetType: str + Status: str + Extension: str + Special: str + Created: str + DoneTime: str + Updated: str + MinutesLeft: str + + +GetUserClaimsResponse = list[GetUserClaimsResponseEntity] From 8b4628365b0dbe973bdfe94b4cf1898a8b342122 Mon Sep 17 00:00:00 2001 From: amine Date: Sun, 20 Jul 2025 19:05:41 +0200 Subject: [PATCH 05/14] fix: implement more types --- retroachievements/client.py | 16 +++++++++---- retroachievements/types.py | 46 +++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/retroachievements/client.py b/retroachievements/client.py index e330e35..dd7ee6b 100644 --- a/retroachievements/client.py +++ b/retroachievements/client.py @@ -18,7 +18,11 @@ GetUserAwardsResponse, GetUserClaimsResponse, GetUserCompletionProgressResponseEntity, + GetUserGameRankAndScoreResponse, + GetUserPointsResponse, + GetUserProgressResponse, GetUserRecentAchievementsResponse, + GetUserRecentlyPlayedGamesResponse, UserProfileResponse, ) @@ -197,7 +201,9 @@ def get_user_claims(self, user: str) -> GetUserClaimsResponse: result = self._call_api("API_GetUserClaims.php?", {"u": user}).json() return result - def get_user_game_rank_and_score(self, user: str, game: int) -> dict: + def get_user_game_rank_and_score( + self, user: str, game: int + ) -> GetUserGameRankAndScoreResponse: """ Get a user's rank and score in a game @@ -210,7 +216,7 @@ def get_user_game_rank_and_score(self, user: str, game: int) -> dict: ).json() return result - def get_user_points(self, user: str) -> dict: + def get_user_points(self, user: str) -> GetUserPointsResponse: """ Get a user's total hardcore and softcore points @@ -220,7 +226,7 @@ def get_user_points(self, user: str) -> dict: result = self._call_api("API_GetUserPoints.php?", {"u": user}).json() return result - def get_user_progress(self, user: str, game: str | int) -> dict: + def get_user_progress(self, user: str, game: str | int) -> GetUserProgressResponse: """ Get a user's progress in a game @@ -246,7 +252,7 @@ def get_user_progress(self, user: str, game: str | int) -> dict: def get_user_recently_played_games( self, user: str, count: int = 10, offset: int = 0 - ) -> dict: + ) -> GetUserRecentlyPlayedGamesResponse: """ Get a user's recently played games @@ -262,7 +268,7 @@ def get_user_recently_played_games( def get_user_summary( self, user: str, games: int = 0, achievements: int = 10 - ) -> dict: + ) -> GetUserSummaryResponse: """ Get a user's exhaustive profile metadata diff --git a/retroachievements/types.py b/retroachievements/types.py index 7ac9ccb..e199902 100644 --- a/retroachievements/types.py +++ b/retroachievements/types.py @@ -293,3 +293,49 @@ class GetUserClaimsResponseEntity(TypedDict): GetUserClaimsResponse = list[GetUserClaimsResponseEntity] + + +class GetUserGameRankAndScoreResponseEntity(TypedDict): + User: str + TotalScore: str + LastAward: str + UserRank: str + + +GetUserGameRankAndScoreResponse = list[GetUserGameRankAndScoreResponseEntity] + + +class GetUserPointsResponse(TypedDict): + Points: int + SoftcorePoints: int + + +class UserProgressResponseEntity(TypedDict): + NumPossibleAchievements: str + PossibleScore: str + NumAchieved: int | str + ScoreAchieved: int | str + NumAchievedHardcore: int | str + ScoreAchievedHardcore: int | str + + +GetUserProgressResponse = dict[str, UserProgressResponseEntity] + + +class UserRecentlyPlayedGameResponseEntity(TypedDict): + GameID: str + ConsoleID: str + ConsoleName: str + Title: str + ImageIcon: str + LastPlayed: str + MyVote: Literal["1", "2", "3", "4", "5"] | None + NumPossibleAchievements: str + PossibleScore: str + NumAchieved: int | str + ScoreAchieved: int | str + NumAchievedHardcore: int | str + ScoreAchievedHardcore: int | str + + +GetUserRecentlyPlayedGamesResponse = list[UserRecentlyPlayedGameResponseEntity] From ee8c69cbe4a1fe126e8ec18e17736c9000010d9e Mon Sep 17 00:00:00 2001 From: amine Date: Sun, 20 Jul 2025 19:26:43 +0200 Subject: [PATCH 06/14] fix: implement more response types and move them to individual modules --- retroachievements/client.py | 36 ++++++-- .../models/get_game_leaderboards_response.py | 25 ++++++ retroachievements/models/get_game_response.py | 21 +++++ .../get_user_completed_games_response.py | 16 ++++ .../models/get_user_set_requests_response.py | 15 ++++ .../models/get_user_summary_response.py | 83 +++++++++++++++++++ .../get_user_want_to_play_list_response.py | 17 ++++ .../models/get_users_following_me_response.py | 15 ++++ .../models/get_users_i_follow_response.py | 15 ++++ 9 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 retroachievements/models/get_game_leaderboards_response.py create mode 100644 retroachievements/models/get_game_response.py create mode 100644 retroachievements/models/get_user_completed_games_response.py create mode 100644 retroachievements/models/get_user_set_requests_response.py create mode 100644 retroachievements/models/get_user_summary_response.py create mode 100644 retroachievements/models/get_user_want_to_play_list_response.py create mode 100644 retroachievements/models/get_users_following_me_response.py create mode 100644 retroachievements/models/get_users_i_follow_response.py diff --git a/retroachievements/client.py b/retroachievements/client.py index dd7ee6b..0fcc6b6 100644 --- a/retroachievements/client.py +++ b/retroachievements/client.py @@ -4,6 +4,24 @@ import requests from retroachievements import __version__ +from retroachievements.models.get_game_leaderboards_response import ( + GetGameLeaderboardsResponse, +) +from retroachievements.models.get_game_response import GetGameResponse +from retroachievements.models.get_user_completed_games_response import ( + GetUserCompletedGamesResponse, +) +from retroachievements.models.get_user_set_requests_response import ( + GetUserSetRequestsResponse, +) +from retroachievements.models.get_user_summary_response import GetUserSummaryResponse +from retroachievements.models.get_user_want_to_play_list_response import ( + GetUserWantToPlayListResponse, +) +from retroachievements.models.get_users_following_me_response import ( + GetUsersFollowingMeResponse, +) +from retroachievements.models.get_users_i_follow_response import GetUsersIFollowResponse from retroachievements.types import ( DatedUserAchievementResponse, GetAchievementCountResponse, @@ -294,7 +312,7 @@ def get_user_summary( ).json() return result - def get_user_completed_games(self, user: str) -> dict: + def get_user_completed_games(self, user: str) -> GetUserCompletedGamesResponse: """ Get a user's completed games @@ -314,7 +332,7 @@ def get_user_completed_games(self, user: str) -> dict: def get_user_want_to_play_list( self, user: str, count: int = 100, offset: int = 0 - ) -> dict: + ) -> GetUserWantToPlayListResponse: """ Get a user's 'Want to Play' list @@ -328,7 +346,9 @@ def get_user_want_to_play_list( ).json() return result - def get_users_i_follow(self, user: str, count: int = 100, offset: int = 0) -> dict: + def get_users_i_follow( + self, user: str, count: int = 100, offset: int = 0 + ) -> GetUsersIFollowResponse: """ Get a list of users that the specified user follows @@ -344,7 +364,7 @@ def get_users_i_follow(self, user: str, count: int = 100, offset: int = 0) -> di def get_users_following_me( self, user: str, count: int = 100, offset: int = 0 - ) -> dict: + ) -> GetUsersFollowingMeResponse: """ Get a list of users that follow the specified user @@ -358,7 +378,9 @@ def get_users_following_me( ).json() return result - def get_user_set_requests(self, user: str, list_type: int = 0) -> dict: + def get_user_set_requests( + self, user: str, list_type: int = 0 + ) -> GetUserSetRequestsResponse: """ Get a user's set requests @@ -376,7 +398,7 @@ def get_user_set_requests(self, user: str, list_type: int = 0) -> dict: # Game endpoints - def get_game(self, game: int) -> dict: + def get_game(self, game: int) -> GetGameResponse: """ Get basic metadata about a game @@ -463,7 +485,7 @@ def get_game_rank_and_score( def get_game_leaderboards( self, game: int, count: int = 100, offset: int = 0 - ) -> dict: + ) -> GetGameLeaderboardsResponse: """ Get the leaderboards for a game diff --git a/retroachievements/models/get_game_leaderboards_response.py b/retroachievements/models/get_game_leaderboards_response.py new file mode 100644 index 0000000..0f50bc2 --- /dev/null +++ b/retroachievements/models/get_game_leaderboards_response.py @@ -0,0 +1,25 @@ +from typing import TypedDict + + +class TopEntryEntity(TypedDict): + User: str + ULID: str + Score: str + FormattedScore: str + + +class GetGameLeaderboardsResponseEntity(TypedDict): + ID: int + RankAsc: bool + Title: str + Description: str + Format: str + Author: str + AuthorULID: str + TopEntry: TopEntryEntity + + +class GetGameLeaderboardsResponse(TypedDict): + Count: int + Total: int + Results: list[GetGameLeaderboardsResponseEntity] diff --git a/retroachievements/models/get_game_response.py b/retroachievements/models/get_game_response.py new file mode 100644 index 0000000..bed85b4 --- /dev/null +++ b/retroachievements/models/get_game_response.py @@ -0,0 +1,21 @@ +from typing import TypedDict + + +class GetGameResponse(TypedDict): + ID: int + Title: str + ForumTopicID: int + ConsoleID: int + ConsoleName: str + Flags: int + ImageIcon: str + GameIcon: str + ImageTitle: str + ImageIngame: str + ImageBoxArt: str + Publisher: str + Developer: str + Genre: str + Released: str + GameTitle: str + Console: str diff --git a/retroachievements/models/get_user_completed_games_response.py b/retroachievements/models/get_user_completed_games_response.py new file mode 100644 index 0000000..e65ad44 --- /dev/null +++ b/retroachievements/models/get_user_completed_games_response.py @@ -0,0 +1,16 @@ +from typing import Literal, TypedDict + + +class UserCompletedGamesResponseEntity(TypedDict): + GameID: str + Title: str + ImageIcon: str + ConsoleID: str + ConsoleName: str + MaxPossible: str + NumAwarded: str + PctWon: str + HardcoreMode: Literal["0", "1"] + + +GetUserCompletedGamesResponse = list[UserCompletedGamesResponseEntity] diff --git a/retroachievements/models/get_user_set_requests_response.py b/retroachievements/models/get_user_set_requests_response.py new file mode 100644 index 0000000..2bcada1 --- /dev/null +++ b/retroachievements/models/get_user_set_requests_response.py @@ -0,0 +1,15 @@ +from typing import TypedDict + + +class UserSetRequestEntity(TypedDict): + GameID: int + Title: str + ConsoleID: int + ConsoleName: str + ImageIcon: str + + +class GetUserSetRequestsResponse(TypedDict): + RequestedSets: list[UserSetRequestEntity] + TotalRequests: int + PointsForNext: int diff --git a/retroachievements/models/get_user_summary_response.py b/retroachievements/models/get_user_summary_response.py new file mode 100644 index 0000000..f211b41 --- /dev/null +++ b/retroachievements/models/get_user_summary_response.py @@ -0,0 +1,83 @@ +from typing import Literal, TypedDict + + +class RecentlyPlayedGameEntity(TypedDict): + GameID: str + ConsoleID: str + ConsoleName: str + Title: str + ImageIcon: str + LastPlayed: str + + +class LastGameEntity(TypedDict): + ID: int + Title: str + ConsoleID: int + ForumTopicID: int + Flags: int + ImageIcon: str + ImageTitle: str + ImageIngame: str + ImageBoxArt: str + Publisher: str + Developer: str + Genre: str + Released: str + IsFinal: bool + ConsoleName: str + RichPresencePatch: str + + +class RecentlyAwardedAchievementEntity(TypedDict): + NumPossibleAchievements: str + PossibleScore: str + NumAchieved: int | str + ScoreAchieved: int | str + NumAchievedHardcore: int | str + ScoreAchievedHardcore: int | str + + +class ExtendedRecentAchievementEntity(TypedDict): + ID: str + GameID: str + GameTitle: str + Title: str + Description: str + Points: str + BadgeName: str + IsAwarded: Literal["1"] + DateAwarded: str + HardcoreAchieved: Literal["0"] + + +class GetUserSummaryResponse(TypedDict): + RecentlyPlayedCount: int + RecentlyPlayed: list[RecentlyPlayedGameEntity] + MemberSince: str + + LastActivity: dict[str, str | int] + + RichPresenceMsg: str + LastGameID: str + LastGame: LastGameEntity + ContribCount: str + ContribYield: str + TotalPoints: str + TotalSoftcorePoints: str + TotalTruePoints: str + Permissions: str + Untracked: Literal["0", "1"] + ID: str + UserWallActive: Literal["0", "1"] + Motto: str + Rank: int + Awarded: dict[str, RecentlyAwardedAchievementEntity] + + RecentAchievements: dict[str, dict[str, ExtendedRecentAchievementEntity]] + + Points: str + SoftcorePoints: str + UserPic: str + TotalRanked: int + Status: str diff --git a/retroachievements/models/get_user_want_to_play_list_response.py b/retroachievements/models/get_user_want_to_play_list_response.py new file mode 100644 index 0000000..551ea0b --- /dev/null +++ b/retroachievements/models/get_user_want_to_play_list_response.py @@ -0,0 +1,17 @@ +from typing import TypedDict + + +class UserWantToPlayListEntity(TypedDict): + ID: int + Title: str + ImageIcon: str + ConsoleID: int + ConsoleName: str + PointsTotal: int + AchievementsPublished: int + + +class GetUserWantToPlayListResponse(TypedDict): + Count: int + Total: int + Results: list[UserWantToPlayListEntity] diff --git a/retroachievements/models/get_users_following_me_response.py b/retroachievements/models/get_users_following_me_response.py new file mode 100644 index 0000000..1e18e79 --- /dev/null +++ b/retroachievements/models/get_users_following_me_response.py @@ -0,0 +1,15 @@ +from typing import TypedDict + + +class GetUsersFollowingMeResponseEntity(TypedDict): + User: str + ULID: str + Points: int + PointsSoftcore: int + AmIFollowing: bool + + +class GetUsersFollowingMeResponse(TypedDict): + Count: int + Total: int + Results: list[GetUsersFollowingMeResponseEntity] diff --git a/retroachievements/models/get_users_i_follow_response.py b/retroachievements/models/get_users_i_follow_response.py new file mode 100644 index 0000000..4cd6252 --- /dev/null +++ b/retroachievements/models/get_users_i_follow_response.py @@ -0,0 +1,15 @@ +from typing import TypedDict + + +class GetUsersIFollowResponseEntity(TypedDict): + User: str + ULID: str + Points: int + PointsSoftcore: int + IsFollowingMe: bool + + +class GetUsersIFollowResponse(TypedDict): + Count: int + Total: int + Results: list[GetUsersIFollowResponseEntity] From a04efabf37c7f825dda573c2a507d349c95b3176 Mon Sep 17 00:00:00 2001 From: amine Date: Sun, 20 Jul 2025 20:24:05 +0200 Subject: [PATCH 07/14] fix: implement more response types --- retroachievements/client.py | 10 ++++++-- .../get_leaderboard_entries_response.py | 16 ++++++++++++ .../get_user_game_leaderboards_response.py | 25 +++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 retroachievements/models/get_leaderboard_entries_response.py create mode 100644 retroachievements/models/get_user_game_leaderboards_response.py diff --git a/retroachievements/client.py b/retroachievements/client.py index 0fcc6b6..e0123ff 100644 --- a/retroachievements/client.py +++ b/retroachievements/client.py @@ -8,9 +8,15 @@ GetGameLeaderboardsResponse, ) from retroachievements.models.get_game_response import GetGameResponse +from retroachievements.models.get_leaderboard_entries_response import ( + GetLeaderboardEntriesResponse, +) from retroachievements.models.get_user_completed_games_response import ( GetUserCompletedGamesResponse, ) +from retroachievements.models.get_user_game_leaderboards_response import ( + GetUserGameLeaderboardsResponse, +) from retroachievements.models.get_user_set_requests_response import ( GetUserSetRequestsResponse, ) @@ -501,7 +507,7 @@ def get_game_leaderboards( def get_leaderboard_entries( self, leaderboard: int, count: int = 100, offset: int = 0 - ) -> dict: + ) -> GetLeaderboardEntriesResponse: """ Get the entries of a leaderboard @@ -518,7 +524,7 @@ def get_leaderboard_entries( def get_user_game_leaderboards( self, game: int, user: str, count: int = 200, offset: int = 0 - ) -> dict: + ) -> GetUserGameLeaderboardsResponse: """ Get a user's leaderboard entries for a game diff --git a/retroachievements/models/get_leaderboard_entries_response.py b/retroachievements/models/get_leaderboard_entries_response.py new file mode 100644 index 0000000..089f029 --- /dev/null +++ b/retroachievements/models/get_leaderboard_entries_response.py @@ -0,0 +1,16 @@ +from typing import TypedDict + + +class LeaderboardEntryEntity(TypedDict): + Rank: int + User: str + ULID: str + Score: int + FormattedScore: str + DateSubmitted: str + + +class GetLeaderboardEntriesResponse(TypedDict): + Count: int + Total: int + Results: list[LeaderboardEntryEntity] diff --git a/retroachievements/models/get_user_game_leaderboards_response.py b/retroachievements/models/get_user_game_leaderboards_response.py new file mode 100644 index 0000000..eb7e84e --- /dev/null +++ b/retroachievements/models/get_user_game_leaderboards_response.py @@ -0,0 +1,25 @@ +from typing import TypedDict + + +class UserEntryEntity(TypedDict): + User: str + ULID: str + Score: int + FormattedScore: str + Rank: int + DateUpdated: str + + +class GetUserGameLeaderboardsResponseEntity(TypedDict): + ID: int + RankAsc: bool + Title: str + Description: str + Format: str + UserEntry: UserEntryEntity + + +class GetUserGameLeaderboardsResponse(TypedDict): + Count: int + Total: int + Results: list[GetUserGameLeaderboardsResponseEntity] From 8dd3d2761ec6961436ba00cdf947b4432c090241 Mon Sep 17 00:00:00 2001 From: amine Date: Sun, 20 Jul 2025 20:39:40 +0200 Subject: [PATCH 08/14] chore: improve logging --- retroachievements/client.py | 88 ++++++++++++++----------------------- 1 file changed, 32 insertions(+), 56 deletions(-) diff --git a/retroachievements/client.py b/retroachievements/client.py index e0123ff..f2fe15b 100644 --- a/retroachievements/client.py +++ b/retroachievements/client.py @@ -1,5 +1,5 @@ import datetime -import warnings +import logging import requests @@ -52,6 +52,8 @@ _BASE_URL = "https://retroachievements.org/API/" +logger = logging.getLogger(__name__) + class RAClient: """ @@ -72,26 +74,6 @@ def url_params(self, params: dict[str, str | int | None] | None = None): params.update({"y": self.api_key}) return params - def is_valid_game_csv(self, value: str | int) -> bool: - """ - Validates if the given value is a valid game CSV string or integer. - - Example of valid game CSV: - 12345, - "12345" - "12345, 67890" - "123, 456, 789" - - Example of invalid game ID: - "123a" - "123,,456" - "123, " - """ - if isinstance(value, int): - return True - parts = value.split(",") - return all(part.strip().isdigit() for part in parts if part) - # URL construction def _call_api( self, @@ -250,27 +232,22 @@ def get_user_points(self, user: str) -> GetUserPointsResponse: result = self._call_api("API_GetUserPoints.php?", {"u": user}).json() return result - def get_user_progress(self, user: str, game: str | int) -> GetUserProgressResponse: + def get_user_progress(self, user: str, games: list[int]) -> GetUserProgressResponse: """ - Get a user's progress in a game + Get a user's progress in a game. Params: - u: Username or ULID to query - i: Game ID to query - - Information: - Unless you are explicitly wanting summary progress details for specific game IDs, get_user_completion_progress will almost certainly be better-suited for your use case. + u: Username or ULID to query. + i: List of games IDs to query separated by a comma. """ - warnings.warn( - "Unless you are explicitly wanting summary progress details for specific game IDs, get_user_completion_progress will almost certainly be better-suited for your use case.", - Warning, - stacklevel=2, + logger.warning( + "Unless you are explicitly wanting summary progress details for specific " + "game IDs, get_user_completion_progress will almost certainly be " + "better-suited for your use case." ) - if not self.is_valid_game_csv(game): - raise ValueError("Invalid game ID or CSV format") - result = self._call_api( - "API_GetUserProgress.php?", {"u": user, "i": game} + "API_GetUserProgress.php?", + {"u": user, "i": ",".join([str(game) for game in games])}, ).json() return result @@ -291,7 +268,7 @@ def get_user_recently_played_games( return result def get_user_summary( - self, user: str, games: int = 0, achievements: int = 10 + self, user: str, games_count: int = 0, achievements_count: int = 10 ) -> GetUserSummaryResponse: """ Get a user's exhaustive profile metadata @@ -300,21 +277,20 @@ def get_user_summary( u: Username or ULID to query g: Number of recent games to fetch, default = 0 a: Number of recent achievements to fetch, default = 10 - - Information: - This endpoint is known to be slow, and often results in over-fetching. For basic user profile information, try the get_user_profile endpoint. For user completion and game progress information, try the get_user_completion_progress endpoint. - - Recent achievements are pulled from recent games, so if you ask for 1 game and 10 achievements, and the user has only earned 8 achievements in the most recent game, you'll only get 8 recent achievements back. Similarly, with the default of 0 recent games, no recent achievements will be returned. """ - warnings.warn( - "This endpoint is known to be slow, and often results in over-fetching. For basic user profile information, try the get_user_profile endpoint. For user completion and game progress information, try the get_user_completion_progress endpoint." - "Recent achievements are pulled from recent games, so if you ask for 1 game and 10 achievements, and the user has only earned 8 achievements in the most recent game, you'll only get 8 recent achievements back. Similarly, with the default of 0 recent games, no recent achievements will be returned.", - Warning, - stacklevel=2, + logger.warning( + "This endpoint is known to be slow, and often results in over-fetching. For " + "basic user profile information, try the get_user_profile endpoint. For user " + "completion and game progress information, try the " + "get_user_completion_progress endpoint. Recent achievements are pulled from " + "recent games, so if you ask for 1 game and 10 achievements, and the user " + "has only earned 8 achievements in the most recent game, you'll only get 8 " + "recent achievements back. Similarly, with the default of 0 recent games, no " + "recent achievements will be returned.", ) result = self._call_api( "API_GetUserSummary.php?", - {"u": user, "g": games, "a": achievements}, + {"u": user, "g": games_count, "a": achievements_count}, ).json() return result @@ -328,10 +304,9 @@ def get_user_completed_games(self, user: str) -> GetUserCompletedGamesResponse: Information: This endpoint is considered "legacy". The get_user_completion_progress endpoint will almost always be a better fit for your use case. """ - warnings.warn( - "This endpoint is considered 'legacy'. The get_user_completion_progress endpoint will almost always be a better fit for your use case.", - DeprecationWarning, - stacklevel=2, + logger.warning( + "This endpoint is considered 'legacy'. The get_user_completion_progress " + "endpoint will almost always be a better fit for your use case." ) result = self._call_api("API_GetUserCompletedGames.php?", {"u": user}).json() return result @@ -414,7 +389,9 @@ def get_game(self, game: int) -> GetGameResponse: result = self._call_api("API_GetGame.php?", {"i": game}).json() return result - def get_game_extended(self, game: int, focus: int = 3) -> GetGameExtendedResponse: + def get_game_extended( + self, game: int, get_unofficial_achievements: bool = False + ) -> GetGameExtendedResponse: """ Get extended metadata about a game @@ -422,10 +399,9 @@ def get_game_extended(self, game: int, focus: int = 3) -> GetGameExtendedRespons i: The game ID to query f: Set to 3 for Official achievements, 5 to see Unofficial / Demoted achievements, default = 3 """ - if focus not in [3, 5]: - raise ValueError("Invalid set type selected. Must be 3 or 5.") + unofficial = 5 if get_unofficial_achievements else 3 result = self._call_api( - "API_GetGameExtended.php?", {"i": game, "f": focus} + "API_GetGameExtended.php?", {"i": game, "f": unofficial} ).json() return result From 88356a21f56f1635ea21f2a3d3a2d1b2f18f2af1 Mon Sep 17 00:00:00 2001 From: amine Date: Sun, 20 Jul 2025 21:09:41 +0200 Subject: [PATCH 09/14] fix: add more types --- retroachievements/client.py | 19 ++++++-- .../models/get_achievement_of_the_week.py | 47 +++++++++++++++++++ .../models/get_achievement_unlocks.py | 39 +++++++++++++++ retroachievements/models/get_comments.py | 14 ++++++ retroachievements/models/get_console_ids.py | 12 +++++ retroachievements/models/get_game_list.py | 18 +++++++ 6 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 retroachievements/models/get_achievement_of_the_week.py create mode 100644 retroachievements/models/get_achievement_unlocks.py create mode 100644 retroachievements/models/get_comments.py create mode 100644 retroachievements/models/get_console_ids.py create mode 100644 retroachievements/models/get_game_list.py diff --git a/retroachievements/client.py b/retroachievements/client.py index f2fe15b..bacfd94 100644 --- a/retroachievements/client.py +++ b/retroachievements/client.py @@ -4,9 +4,18 @@ import requests from retroachievements import __version__ +from retroachievements.models.get_achievement_of_the_week import ( + GetAchievementOfTheWeekResponse, +) +from retroachievements.models.get_achievement_unlocks import ( + GetAchievementUnlocksResponse, +) +from retroachievements.models.get_comments import GetCommentsResponse +from retroachievements.models.get_console_ids import GetConsoleIdsResponse from retroachievements.models.get_game_leaderboards_response import ( GetGameLeaderboardsResponse, ) +from retroachievements.models.get_game_list import GetGameListResponse from retroachievements.models.get_game_response import GetGameResponse from retroachievements.models.get_leaderboard_entries_response import ( GetLeaderboardEntriesResponse, @@ -518,7 +527,7 @@ def get_user_game_leaderboards( # System Endpoints - def get_console_ids(self) -> list: + def get_console_ids(self) -> GetConsoleIdsResponse: """ Get the complete list of console ID and name pairs on the site @@ -530,7 +539,7 @@ def get_console_ids(self) -> list: def get_game_list( self, system: int, has_achievements: int = 0, hashes: int = 0 - ) -> dict: + ) -> GetGameListResponse: """ Get the complete list of games for a console @@ -552,7 +561,7 @@ def get_game_list( def get_achievement_unlocks( self, achievement: int, count: int = 50, offset: int = 0 - ) -> dict: + ) -> GetAchievementUnlocksResponse: """ Get the unlocks for an achievement @@ -576,7 +585,7 @@ def get_comments( count: int = 100, offset: int = 0, sort: str = "submitted", - ) -> dict: + ) -> GetCommentsResponse: """ Get comments for a game or achievement @@ -669,7 +678,7 @@ def get_top_ten_users(self) -> GetTopTenUsersResponse: # Event Endpoints - def get_achievement_of_the_week(self) -> dict: + def get_achievement_of_the_week(self) -> GetAchievementOfTheWeekResponse: """ Get the achievement of the week diff --git a/retroachievements/models/get_achievement_of_the_week.py b/retroachievements/models/get_achievement_of_the_week.py new file mode 100644 index 0000000..a8597e1 --- /dev/null +++ b/retroachievements/models/get_achievement_of_the_week.py @@ -0,0 +1,47 @@ +from typing import TypedDict + + +class AchievementOfTheWeekResponseEntity(TypedDict): + ID: str + Title: str + Description: str + Points: str + TrueRatio: str + Author: str + DateCreated: str + DateModified: str + BadgeName: str + BadgeURL: str + + +class ConsoleEntity(TypedDict): + ID: str + Title: str + + +class ForumTopicEntity(TypedDict): + ID: int + + +class GameEntity(TypedDict): + ID: str + Title: str + + +class UnlocksEntity(TypedDict): + User: str + RAPoints: str + RASoftcorePoints: str + DateAwarded: str + HardcoreMode: str + + +class GetAchievementOfTheWeekResponse(TypedDict): + Achievement: AchievementOfTheWeekResponseEntity + Console: ConsoleEntity + ForumTopic: ForumTopicEntity + Game: GameEntity + StartAt: str + TotalPlayers: str + Unlocks: list[UnlocksEntity] + UnlocksCount: str diff --git a/retroachievements/models/get_achievement_unlocks.py b/retroachievements/models/get_achievement_unlocks.py new file mode 100644 index 0000000..5a0aac4 --- /dev/null +++ b/retroachievements/models/get_achievement_unlocks.py @@ -0,0 +1,39 @@ +from typing import TypedDict + + +class AchievementUnlocksResponseEntity(TypedDict): + User: str + RAPoints: str + RASoftcorePoints: str + DateAwarded: str + HardcoreMode: str + + +class GetAchievementUnlocksResponseEntity(TypedDict): + ID: str + Title: str + Description: str + Points: str + TrueRatio: str + Author: str + DateCreated: str + DateModified: str + + +class ConsoleEntity(TypedDict): + ID: str + Title: str + + +class GameEntity(TypedDict): + ID: str + Title: str + + +class GetAchievementUnlocksResponse(TypedDict): + Achievement: GetAchievementUnlocksResponseEntity + Console: ConsoleEntity + Game: GameEntity + UnlocksCount: int + TotalPlayers: int + Unlocks: list[AchievementUnlocksResponseEntity] diff --git a/retroachievements/models/get_comments.py b/retroachievements/models/get_comments.py new file mode 100644 index 0000000..9f70192 --- /dev/null +++ b/retroachievements/models/get_comments.py @@ -0,0 +1,14 @@ +from typing import TypedDict + + +class CommentEntity(TypedDict): + User: str + ULID: str + Submitted: str + CommentText: str + + +class GetCommentsResponse(TypedDict): + Count: int + Total: int + Results: list[CommentEntity] diff --git a/retroachievements/models/get_console_ids.py b/retroachievements/models/get_console_ids.py new file mode 100644 index 0000000..59ec97b --- /dev/null +++ b/retroachievements/models/get_console_ids.py @@ -0,0 +1,12 @@ +from typing import TypedDict + + +class GetConsoleIdsResponseEntity(TypedDict): + ID: str + Name: str + IconURL: str + Active: bool + IsGameSystem: bool + + +GetConsoleIdsResponse = list[GetConsoleIdsResponseEntity] diff --git a/retroachievements/models/get_game_list.py b/retroachievements/models/get_game_list.py new file mode 100644 index 0000000..eb51efa --- /dev/null +++ b/retroachievements/models/get_game_list.py @@ -0,0 +1,18 @@ +from typing import TypedDict + + +class RawGameListEntity(TypedDict): + Title: str + ID: str + ConsoleID: str + ConsoleName: str + ImageIcon: str + NumAchievements: int + NumLeaderboards: int + Points: int + DateModified: str + ForumTopicID: int + Hashes: list[str] | None + + +GetGameListResponse = list[RawGameListEntity] From f15636e2b6f7cea6a2bfbfdcd7a6a309eff0187d Mon Sep 17 00:00:00 2001 From: amine Date: Sun, 20 Jul 2025 21:28:40 +0200 Subject: [PATCH 10/14] fix: implement remaining types --- .pre-commit-config.yaml | 10 +++--- retroachievements/client.py | 34 +++++++++++++++---- .../models/get_achievement_ticket_stats.py | 9 +++++ .../models/get_developer_ticket_stats.py | 10 ++++++ .../models/get_game_ticket_stats.py | 13 +++++++ .../models/get_most_recent_tickets.py | 32 +++++++++++++++++ .../models/get_most_ticketed_games.py | 14 ++++++++ retroachievements/models/get_ticket_by_id.py | 26 ++++++++++++++ 8 files changed, 137 insertions(+), 11 deletions(-) create mode 100644 retroachievements/models/get_achievement_ticket_stats.py create mode 100644 retroachievements/models/get_developer_ticket_stats.py create mode 100644 retroachievements/models/get_game_ticket_stats.py create mode 100644 retroachievements/models/get_most_recent_tickets.py create mode 100644 retroachievements/models/get_most_ticketed_games.py create mode 100644 retroachievements/models/get_ticket_by_id.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2853709..6c78cec 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,11 +23,11 @@ repos: entry: pyright types: [python] language: system -- repo: https://github.com/jendrikseipp/vulture - rev: 'v2.14' - hooks: - - id: vulture - name: Check unused Python code +# - repo: https://github.com/jendrikseipp/vulture +# rev: 'v2.14' +# hooks: +# - id: vulture +# name: Check unused Python code - repo: https://github.com/astral-sh/uv-pre-commit rev: 0.5.14 hooks: diff --git a/retroachievements/client.py b/retroachievements/client.py index bacfd94..40548c8 100644 --- a/retroachievements/client.py +++ b/retroachievements/client.py @@ -7,19 +7,33 @@ from retroachievements.models.get_achievement_of_the_week import ( GetAchievementOfTheWeekResponse, ) +from retroachievements.models.get_achievement_ticket_stats import ( + GetAchievementTicketStatsResponse, +) from retroachievements.models.get_achievement_unlocks import ( GetAchievementUnlocksResponse, ) from retroachievements.models.get_comments import GetCommentsResponse from retroachievements.models.get_console_ids import GetConsoleIdsResponse +from retroachievements.models.get_developer_ticket_stats import ( + GetDeveloperTicketStatsResponse, +) from retroachievements.models.get_game_leaderboards_response import ( GetGameLeaderboardsResponse, ) from retroachievements.models.get_game_list import GetGameListResponse from retroachievements.models.get_game_response import GetGameResponse +from retroachievements.models.get_game_ticket_stats import GetGameTicketStatsResponse from retroachievements.models.get_leaderboard_entries_response import ( GetLeaderboardEntriesResponse, ) +from retroachievements.models.get_most_recent_tickets import ( + GetMostRecentTicketsResponse, +) +from retroachievements.models.get_most_ticketed_games import ( + GetMostTicketedGamesResponse, +) +from retroachievements.models.get_ticket_by_id import GetTicketByIdResponse from retroachievements.models.get_user_completed_games_response import ( GetUserCompletedGamesResponse, ) @@ -690,7 +704,7 @@ def get_achievement_of_the_week(self) -> GetAchievementOfTheWeekResponse: # Ticket Endpoints - def get_ticket_data(self, ticket_id: int) -> dict: + def get_ticket_by_id(self, ticket_id: int) -> GetTicketByIdResponse: """ Get the data for a specific ticket @@ -700,7 +714,7 @@ def get_ticket_data(self, ticket_id: int) -> dict: result = self._call_api("API_GetTicketData.php?", {"i": ticket_id}).json() return result - def get_most_ticketed_games(self, focus: int = 1) -> dict: + def get_most_ticketed_games(self, focus: int = 1) -> GetMostTicketedGamesResponse: """ Get the most ticketed games @@ -710,7 +724,9 @@ def get_most_ticketed_games(self, focus: int = 1) -> dict: result = self._call_api("API_GetTicketData.php?", {"f": focus}).json() return result - def get_most_recent_tickets(self, count: int = 10, offset: int = 0) -> dict: + def get_most_recent_tickets( + self, count: int = 10, offset: int = 0 + ) -> GetMostRecentTicketsResponse: """ Get the most recent tickets @@ -723,7 +739,9 @@ def get_most_recent_tickets(self, count: int = 10, offset: int = 0) -> dict: ).json() return result - def get_game_ticket_stats(self, game: int, focus: int = 3, depth: int = 0) -> dict: + def get_game_ticket_stats( + self, game: int, focus: int = 3, depth: int = 0 + ) -> GetGameTicketStatsResponse: """ Get the ticket stats for a game @@ -741,7 +759,9 @@ def get_game_ticket_stats(self, game: int, focus: int = 3, depth: int = 0) -> di ).json() return result - def get_developer_ticket_stats(self, username: str, ulid: str) -> dict: + def get_developer_ticket_stats( + self, username: str, ulid: str + ) -> GetDeveloperTicketStatsResponse: """ Get the ticket stats for a developer @@ -754,7 +774,9 @@ def get_developer_ticket_stats(self, username: str, ulid: str) -> dict: ).json() return result - def get_achievement_ticket_stats(self, achievement: int) -> dict: + def get_achievement_ticket_stats( + self, achievement: int + ) -> GetAchievementTicketStatsResponse: """ Get the ticket stats for an achievement diff --git a/retroachievements/models/get_achievement_ticket_stats.py b/retroachievements/models/get_achievement_ticket_stats.py new file mode 100644 index 0000000..4cb66d5 --- /dev/null +++ b/retroachievements/models/get_achievement_ticket_stats.py @@ -0,0 +1,9 @@ +from typing import TypedDict + + +class GetAchievementTicketStatsResponse(TypedDict): + achievementId: int + achievementTitle: str + achievementDescription: str + url: str + openTickets: int diff --git a/retroachievements/models/get_developer_ticket_stats.py b/retroachievements/models/get_developer_ticket_stats.py new file mode 100644 index 0000000..439d72e --- /dev/null +++ b/retroachievements/models/get_developer_ticket_stats.py @@ -0,0 +1,10 @@ +from typing import TypedDict + + +class GetDeveloperTicketStatsResponse(TypedDict): + user: str + open: int + closed: int + resolved: int + total: int + url: str diff --git a/retroachievements/models/get_game_ticket_stats.py b/retroachievements/models/get_game_ticket_stats.py new file mode 100644 index 0000000..e6fa623 --- /dev/null +++ b/retroachievements/models/get_game_ticket_stats.py @@ -0,0 +1,13 @@ +from typing import TypedDict + +from retroachievements.models.get_most_recent_tickets import TicketEntity + + +class GetGameTicketStatsResponse(TypedDict): + gameId: int + gameTitle: str + consoleName: str + openTickets: int + url: str + + tickets: list[TicketEntity] diff --git a/retroachievements/models/get_most_recent_tickets.py b/retroachievements/models/get_most_recent_tickets.py new file mode 100644 index 0000000..65ee916 --- /dev/null +++ b/retroachievements/models/get_most_recent_tickets.py @@ -0,0 +1,32 @@ +from typing import TypedDict + + +class TicketEntity(TypedDict): + id: int + achievementId: int + achievementTitle: str + achievementDesc: str + points: int + badgeName: str + achievementAuthor: str + gameId: int + consoleName: str + gameTitle: str + gameIcon: str + reportedAt: str + reportType: int + reportState: int + hardcore: bool | None + reportNotes: str + reportedBy: str + resolvedAt: str | None + resolvedBy: str | None + reportStateDescription: str + reportTypeDescription: str + url: str + + +class GetMostRecentTicketsResponse(TypedDict): + recentTickets: list[TicketEntity] + openTickets: int + url: str diff --git a/retroachievements/models/get_most_ticketed_games.py b/retroachievements/models/get_most_ticketed_games.py new file mode 100644 index 0000000..2f10c1b --- /dev/null +++ b/retroachievements/models/get_most_ticketed_games.py @@ -0,0 +1,14 @@ +from typing import TypedDict + + +class ReportedGameEntity(TypedDict): + gameId: int + gameTitle: str + gameIcon: str + console: str + openTickets: int + + +class GetMostTicketedGamesResponse(TypedDict): + mostReportedGames: list[ReportedGameEntity] + url: str diff --git a/retroachievements/models/get_ticket_by_id.py b/retroachievements/models/get_ticket_by_id.py new file mode 100644 index 0000000..ab3b602 --- /dev/null +++ b/retroachievements/models/get_ticket_by_id.py @@ -0,0 +1,26 @@ +from typing import TypedDict + + +class GetTicketByIdResponse(TypedDict): + id: int + achievementId: int + achievementTitle: str + achievementDesc: str + points: int + badgeName: str + achievementAuthor: str + gameId: int + consoleName: str + gameTitle: str + gameIcon: str + reportedAt: str + reportType: int + reportState: int + hardcore: bool | None + reportNotes: str + reportedBy: str + resolvedAt: str | None + resolvedBy: str | None + reportStateDescription: str + reportTypeDescription: str + url: str From aba555d11df9bb67f54417f812fd3b83deb29e86 Mon Sep 17 00:00:00 2001 From: amine Date: Sun, 20 Jul 2025 21:32:11 +0200 Subject: [PATCH 11/14] fix: package __init__ --- retroachievements/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/retroachievements/__init__.py b/retroachievements/__init__.py index fdff482..e53d960 100644 --- a/retroachievements/__init__.py +++ b/retroachievements/__init__.py @@ -8,7 +8,9 @@ """ __title__ = "retroachievements-api" -__authors__ = "drisc" +__authors__ = "drisc, amine456" __version__ = "1.0.0" from .client import RAClient + +__all__ = ["RAClient"] From d56be17816bb0b540eeaea383b4d2323bd0b8ed0 Mon Sep 17 00:00:00 2001 From: amine Date: Sun, 20 Jul 2025 21:33:28 +0200 Subject: [PATCH 12/14] docs: add CONTRIBUTING.md --- CONTRIBUTING.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 854139a..ab1f996 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1 +1,9 @@ # Contributing + +### 🔧 Quickstart +- Install [`uv`](https://github.com/astral-sh/uv) + +```bash +uv sync --all-packages +pre-commit install +``` From 9c8e9ccbdb14fd96b81789050fd9c2b4b88611d4 Mon Sep 17 00:00:00 2001 From: amine Date: Sun, 20 Jul 2025 22:17:17 +0200 Subject: [PATCH 13/14] refactor: (WIP) split the client into multiple modules --- .gitignore | 13 ++ retroachievements/base.py | 43 ++++ retroachievements/client.py | 208 +++++++----------- .../get_achievement_of_the_week.py | 0 .../get_achievement_ticket_stats.py | 0 .../get_achievement_unlocks.py | 0 .../{models => endpoints}/get_comments.py | 0 .../{models => endpoints}/get_console_ids.py | 0 .../get_developer_ticket_stats.py | 0 .../get_game_leaderboards_response.py | 0 .../{models => endpoints}/get_game_list.py | 0 .../get_game_response.py | 0 .../get_game_ticket_stats.py | 2 +- .../get_leaderboard_entries_response.py | 0 .../get_most_recent_tickets.py | 0 .../get_most_ticketed_games.py | 0 .../{models => endpoints}/get_ticket_by_id.py | 0 .../get_user_completed_games_response.py | 0 .../get_user_game_leaderboards_response.py | 0 .../get_user_set_requests_response.py | 0 .../get_user_summary_response.py | 0 .../get_user_want_to_play_list_response.py | 0 .../get_users_following_me_response.py | 0 .../get_users_i_follow_response.py | 0 .../endpoints/user/get_user_profile.py | 33 +++ .../user/get_user_recent_achievements.py | 42 ++++ retroachievements/types.py | 38 ---- 27 files changed, 209 insertions(+), 170 deletions(-) create mode 100644 .gitignore create mode 100644 retroachievements/base.py rename retroachievements/{models => endpoints}/get_achievement_of_the_week.py (100%) rename retroachievements/{models => endpoints}/get_achievement_ticket_stats.py (100%) rename retroachievements/{models => endpoints}/get_achievement_unlocks.py (100%) rename retroachievements/{models => endpoints}/get_comments.py (100%) rename retroachievements/{models => endpoints}/get_console_ids.py (100%) rename retroachievements/{models => endpoints}/get_developer_ticket_stats.py (100%) rename retroachievements/{models => endpoints}/get_game_leaderboards_response.py (100%) rename retroachievements/{models => endpoints}/get_game_list.py (100%) rename retroachievements/{models => endpoints}/get_game_response.py (100%) rename retroachievements/{models => endpoints}/get_game_ticket_stats.py (72%) rename retroachievements/{models => endpoints}/get_leaderboard_entries_response.py (100%) rename retroachievements/{models => endpoints}/get_most_recent_tickets.py (100%) rename retroachievements/{models => endpoints}/get_most_ticketed_games.py (100%) rename retroachievements/{models => endpoints}/get_ticket_by_id.py (100%) rename retroachievements/{models => endpoints}/get_user_completed_games_response.py (100%) rename retroachievements/{models => endpoints}/get_user_game_leaderboards_response.py (100%) rename retroachievements/{models => endpoints}/get_user_set_requests_response.py (100%) rename retroachievements/{models => endpoints}/get_user_summary_response.py (100%) rename retroachievements/{models => endpoints}/get_user_want_to_play_list_response.py (100%) rename retroachievements/{models => endpoints}/get_users_following_me_response.py (100%) rename retroachievements/{models => endpoints}/get_users_i_follow_response.py (100%) create mode 100644 retroachievements/endpoints/user/get_user_profile.py create mode 100644 retroachievements/endpoints/user/get_user_recent_achievements.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37322b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# local testing file +sandbox.py diff --git a/retroachievements/base.py b/retroachievements/base.py new file mode 100644 index 0000000..f6616cc --- /dev/null +++ b/retroachievements/base.py @@ -0,0 +1,43 @@ +import requests + +from retroachievements import __version__ + +_BASE_URL = "https://retroachievements.org/API/" + + +class BaseRAClient: + """ + Main class for accessing the RetroAchievements Web API + """ + + headers = {"User-Agent": "RetroAchievements-api-python/" + __version__} + + def __init__(self, api_key: str): + self.api_key = api_key + + def url_params(self, params: dict[str, str | int | None] | None = None): + """ + Inserts the auth and query params into the request + """ + if params is None: + params = {} + params.update({"y": self.api_key}) + return params + + # URL construction + def call_api( + self, + endpoint: str | None = None, + params: dict[str, str | int | None] | None = None, + timeout: int = 30, + headers: dict[str, str] | None = None, + ): + if endpoint is None: + endpoint = "" + req = requests.get( + f"{_BASE_URL}{endpoint}", + params=self.url_params(params), + timeout=timeout, + headers=headers, + ) + return req diff --git a/retroachievements/client.py b/retroachievements/client.py index 40548c8..df72e33 100644 --- a/retroachievements/client.py +++ b/retroachievements/client.py @@ -1,56 +1,62 @@ import datetime import logging -import requests - -from retroachievements import __version__ -from retroachievements.models.get_achievement_of_the_week import ( +from retroachievements.base import BaseRAClient +from retroachievements.endpoints.get_achievement_of_the_week import ( GetAchievementOfTheWeekResponse, ) -from retroachievements.models.get_achievement_ticket_stats import ( +from retroachievements.endpoints.get_achievement_ticket_stats import ( GetAchievementTicketStatsResponse, ) -from retroachievements.models.get_achievement_unlocks import ( +from retroachievements.endpoints.get_achievement_unlocks import ( GetAchievementUnlocksResponse, ) -from retroachievements.models.get_comments import GetCommentsResponse -from retroachievements.models.get_console_ids import GetConsoleIdsResponse -from retroachievements.models.get_developer_ticket_stats import ( +from retroachievements.endpoints.get_comments import GetCommentsResponse +from retroachievements.endpoints.get_console_ids import GetConsoleIdsResponse +from retroachievements.endpoints.get_developer_ticket_stats import ( GetDeveloperTicketStatsResponse, ) -from retroachievements.models.get_game_leaderboards_response import ( +from retroachievements.endpoints.get_game_leaderboards_response import ( GetGameLeaderboardsResponse, ) -from retroachievements.models.get_game_list import GetGameListResponse -from retroachievements.models.get_game_response import GetGameResponse -from retroachievements.models.get_game_ticket_stats import GetGameTicketStatsResponse -from retroachievements.models.get_leaderboard_entries_response import ( +from retroachievements.endpoints.get_game_list import GetGameListResponse +from retroachievements.endpoints.get_game_response import GetGameResponse +from retroachievements.endpoints.get_game_ticket_stats import GetGameTicketStatsResponse +from retroachievements.endpoints.get_leaderboard_entries_response import ( GetLeaderboardEntriesResponse, ) -from retroachievements.models.get_most_recent_tickets import ( +from retroachievements.endpoints.get_most_recent_tickets import ( GetMostRecentTicketsResponse, ) -from retroachievements.models.get_most_ticketed_games import ( +from retroachievements.endpoints.get_most_ticketed_games import ( GetMostTicketedGamesResponse, ) -from retroachievements.models.get_ticket_by_id import GetTicketByIdResponse -from retroachievements.models.get_user_completed_games_response import ( +from retroachievements.endpoints.get_ticket_by_id import GetTicketByIdResponse +from retroachievements.endpoints.get_user_completed_games_response import ( GetUserCompletedGamesResponse, ) -from retroachievements.models.get_user_game_leaderboards_response import ( +from retroachievements.endpoints.get_user_game_leaderboards_response import ( GetUserGameLeaderboardsResponse, ) -from retroachievements.models.get_user_set_requests_response import ( +from retroachievements.endpoints.get_user_set_requests_response import ( GetUserSetRequestsResponse, ) -from retroachievements.models.get_user_summary_response import GetUserSummaryResponse -from retroachievements.models.get_user_want_to_play_list_response import ( +from retroachievements.endpoints.get_user_summary_response import GetUserSummaryResponse +from retroachievements.endpoints.get_user_want_to_play_list_response import ( GetUserWantToPlayListResponse, ) -from retroachievements.models.get_users_following_me_response import ( +from retroachievements.endpoints.get_users_following_me_response import ( GetUsersFollowingMeResponse, ) -from retroachievements.models.get_users_i_follow_response import GetUsersIFollowResponse +from retroachievements.endpoints.get_users_i_follow_response import ( + GetUsersIFollowResponse, +) +from retroachievements.endpoints.user.get_user_profile import ( + get_user_profile, +) +from retroachievements.endpoints.user.get_user_recent_achievements import ( + get_user_recent_achievements, +) from retroachievements.types import ( DatedUserAchievementResponse, GetAchievementCountResponse, @@ -68,82 +74,22 @@ GetUserGameRankAndScoreResponse, GetUserPointsResponse, GetUserProgressResponse, - GetUserRecentAchievementsResponse, GetUserRecentlyPlayedGamesResponse, - UserProfileResponse, ) -_BASE_URL = "https://retroachievements.org/API/" - logger = logging.getLogger(__name__) -class RAClient: - """ - Main class for accessing the RetroAchievements Web API - """ - - headers = {"User-Agent": "RetroAchievements-api-python/" + __version__} - - def __init__(self, api_key: str): - self.api_key = api_key - - def url_params(self, params: dict[str, str | int | None] | None = None): - """ - Inserts the auth and query params into the request - """ - if params is None: - params = {} - params.update({"y": self.api_key}) - return params - - # URL construction - def _call_api( - self, - endpoint: str | None = None, - params: dict[str, str | int | None] | None = None, - timeout: int = 30, - headers: dict[str, str] | None = None, - ): - if endpoint is None: - endpoint = "" - req = requests.get( - f"{_BASE_URL}{endpoint}", - params=self.url_params(params), - timeout=timeout, - headers=headers, - ) - return req - - # User endpoints - - def get_user_profile(self, user: str, ulid: str) -> UserProfileResponse: - """ - Get a user's profile information +class RAClient(BaseRAClient): + # user endpoints - Params: - u: Username to query - i: ULID to query - """ - result = self._call_api( - "API_GetUserProfile.php?", {"u": user, "i": ulid} - ).json() - return result + def get_user_profile(self, username: str): + return get_user_profile(self, username) def get_user_recent_achievements( - self, user: str, minutes: int = 60 - ) -> GetUserRecentAchievementsResponse: - """ - Get a user's recent achievements - - Params: - u: Username or ULID to query - m: Minutes to look back, default = 60 - """ - result = self._call_api( - "API_GetUserRecentAchievements.php?", {"u": user, "m": minutes} - ).json() - return result + self, username: str, recent_minutes: int | None = 60 + ): + return get_user_recent_achievements(self, username, recent_minutes) def get_user_achievements_earned_between( self, user: str, start: int, end: int @@ -156,7 +102,7 @@ def get_user_achievements_earned_between( f: Epoch timestamp. Time range start. t: Epoch timestamp. Time range end. """ - result = self._call_api( + result = self.call_api( "API_GetAchievementsEarnedBetween.php?", {"u": user, "s": start, "e": end} ).json() return result @@ -171,7 +117,7 @@ def get_user_achievements_earned_on_day( u: Username or ULID to query d: Date in YYYY-MM-DD format, default = now """ - result = self._call_api( + result = self.call_api( "API_GetAchievementsEarnedOnDay.php?", {"u": user, "d": date} ).json() return result @@ -189,7 +135,7 @@ def get_game_info_and_user_progress( """ if awards not in [0, 1]: raise ValueError("Invalid awards value. Must be 0 or 1.") - result = self._call_api( + result = self.call_api( "API_GetGameInfoAndUserProgress.php?", {"u": user, "g": game, "a": awards} ).json() return result @@ -205,7 +151,7 @@ def get_user_completion_progress( c: Count, the number of records to return (default = 100, max = 500) o: Offset, the number of entries to skip (default = 0) """ - result = self._call_api( + result = self.call_api( "API_GetUserCompletionProgress.php?", {"u": user, "c": count, "o": offset} ).json() return result @@ -217,7 +163,7 @@ def get_user_awards(self, user: str) -> GetUserAwardsResponse: Params: u: Username or ULID to query """ - result = self._call_api("API_GetUserAwards.php?", {"u": user}).json() + result = self.call_api("API_GetUserAwards.php?", {"u": user}).json() return result def get_user_claims(self, user: str) -> GetUserClaimsResponse: @@ -227,7 +173,7 @@ def get_user_claims(self, user: str) -> GetUserClaimsResponse: Params: u: Username or ULID to query """ - result = self._call_api("API_GetUserClaims.php?", {"u": user}).json() + result = self.call_api("API_GetUserClaims.php?", {"u": user}).json() return result def get_user_game_rank_and_score( @@ -240,7 +186,7 @@ def get_user_game_rank_and_score( u: Username or ULID to query g: Game ID to query """ - result = self._call_api( + result = self.call_api( "API_GetUserGameRankAndScore.php?", {"u": user, "g": game} ).json() return result @@ -252,7 +198,7 @@ def get_user_points(self, user: str) -> GetUserPointsResponse: Params: u: Username or ULID to query """ - result = self._call_api("API_GetUserPoints.php?", {"u": user}).json() + result = self.call_api("API_GetUserPoints.php?", {"u": user}).json() return result def get_user_progress(self, user: str, games: list[int]) -> GetUserProgressResponse: @@ -268,7 +214,7 @@ def get_user_progress(self, user: str, games: list[int]) -> GetUserProgressRespo "game IDs, get_user_completion_progress will almost certainly be " "better-suited for your use case." ) - result = self._call_api( + result = self.call_api( "API_GetUserProgress.php?", {"u": user, "i": ",".join([str(game) for game in games])}, ).json() @@ -285,7 +231,7 @@ def get_user_recently_played_games( c: Count, the number of records to return (default = 10, max = 50) o: Offset, the number of entries to skip (default = 0) """ - result = self._call_api( + result = self.call_api( "API_GetUserRecentlyPlayedGames.php?", {"u": user, "c": count, "o": offset} ).json() return result @@ -311,7 +257,7 @@ def get_user_summary( "recent achievements back. Similarly, with the default of 0 recent games, no " "recent achievements will be returned.", ) - result = self._call_api( + result = self.call_api( "API_GetUserSummary.php?", {"u": user, "g": games_count, "a": achievements_count}, ).json() @@ -331,7 +277,7 @@ def get_user_completed_games(self, user: str) -> GetUserCompletedGamesResponse: "This endpoint is considered 'legacy'. The get_user_completion_progress " "endpoint will almost always be a better fit for your use case." ) - result = self._call_api("API_GetUserCompletedGames.php?", {"u": user}).json() + result = self.call_api("API_GetUserCompletedGames.php?", {"u": user}).json() return result def get_user_want_to_play_list( @@ -345,7 +291,7 @@ def get_user_want_to_play_list( c: Count, the number of records to return (default = 100, max = 500) o: Offset, the number of entries to skip (default = 0) """ - result = self._call_api( + result = self.call_api( "API_GetUserWantToPlayList.php?", {"u": user, "c": count, "o": offset} ).json() return result @@ -361,7 +307,7 @@ def get_users_i_follow( c: Count, the number of records to return (default = 100, max = 500) o: Offset, the number of entries to skip (default = 0) """ - result = self._call_api( + result = self.call_api( "API_GetUsersIFollow.php?", {"u": user, "c": count, "o": offset} ).json() return result @@ -377,7 +323,7 @@ def get_users_following_me( c: Count, the number of records to return (default = 100, max = 500) o: Offset, the number of entries to skip (default = 0) """ - result = self._call_api( + result = self.call_api( "API_GetUsersFollowingMe.php?", {"u": user, "c": count, "o": offset} ).json() return result @@ -395,7 +341,7 @@ def get_user_set_requests( if list_type not in [0, 1]: raise ValueError("Invalid list type. Must be 0 or 1.") - result = self._call_api( + result = self.call_api( "API_GetUserSetRequests.php?", {"u": user, "t": list_type} ).json() return result @@ -409,7 +355,7 @@ def get_game(self, game: int) -> GetGameResponse: Params: i: The game ID to query """ - result = self._call_api("API_GetGame.php?", {"i": game}).json() + result = self.call_api("API_GetGame.php?", {"i": game}).json() return result def get_game_extended( @@ -423,7 +369,7 @@ def get_game_extended( f: Set to 3 for Official achievements, 5 to see Unofficial / Demoted achievements, default = 3 """ unofficial = 5 if get_unofficial_achievements else 3 - result = self._call_api( + result = self.call_api( "API_GetGameExtended.php?", {"i": game, "f": unofficial} ).json() return result @@ -435,7 +381,7 @@ def get_game_hashes(self, game: int) -> GetGameHashesResponse: Params: i: The game ID to query """ - result = self._call_api("API_GetGameHashes.php?", {"i": game}).json() + result = self.call_api("API_GetGameHashes.php?", {"i": game}).json() return result def get_achievement_count(self, game: int) -> GetAchievementCountResponse: @@ -445,7 +391,7 @@ def get_achievement_count(self, game: int) -> GetAchievementCountResponse: Params: i: The game ID to query """ - result = self._call_api("API_GetAchievementCount.php?", {"i": game}).json() + result = self.call_api("API_GetAchievementCount.php?", {"i": game}).json() return result def get_achievement_distribution( @@ -463,7 +409,7 @@ def get_achievement_distribution( raise ValueError("Invalid achievement type. Must be 0 or 1.") if focus not in [3, 5]: raise ValueError("Invalid set type selected. Must be 3 or 5.") - result = self._call_api( + result = self.call_api( "API_GetAchievementDistribution.php?", {"i": game, "h": achievement_type, "f": focus}, ).json() @@ -481,7 +427,7 @@ def get_game_rank_and_score( """ if list_type not in [0, 1]: raise ValueError("Invalid list type. Must be 0 or 1.") - result = self._call_api( + result = self.call_api( "API_GetGameRankAndScore.php?", {"g": game, "t": list_type} ).json() return result @@ -499,7 +445,7 @@ def get_game_leaderboards( c: Count, the number of records to return (default = 100, max = 500) o: Offset, the number of entries to skip (default = 0) """ - result = self._call_api( + result = self.call_api( "API_GetGameLeaderboards.php?", {"i": game, "c": count, "o": offset} ).json() return result @@ -515,7 +461,7 @@ def get_leaderboard_entries( c: Count, the number of records to return (default = 100, max = 500) o: Offset, the number of entries to skip (default = 0) """ - result = self._call_api( + result = self.call_api( "API_GetLeaderboardEntries.php?", {"i": leaderboard, "c": count, "o": offset}, ).json() @@ -533,7 +479,7 @@ def get_user_game_leaderboards( c: Count, the number of records to return (default = 200, max = 500) o: Offset, the number of entries to skip (default = 0) """ - result = self._call_api( + result = self.call_api( "API_GetUserGameLeaderboards.php?", {"i": game, "u": user, "c": count, "o": offset}, ).json() @@ -548,7 +494,7 @@ def get_console_ids(self) -> GetConsoleIdsResponse: Params: None """ - result = self._call_api("API_GetConsoleIDs.php?", {}).json() + result = self.call_api("API_GetConsoleIDs.php?", {}).json() return result def get_game_list( @@ -566,7 +512,7 @@ def get_game_list( raise ValueError("Invalid has_cheevos value. Must be 0 or 1.") if hashes not in [0, 1]: raise ValueError("Invalid hashes value. Must be 0 or 1.") - result = self._call_api( + result = self.call_api( "API_GetGameList.php?", {"i": system, "f": has_achievements, "h": hashes} ).json() return result @@ -584,7 +530,7 @@ def get_achievement_unlocks( c: Count, the number of records to return (default = 50, max = 500) o: Offset, the number of entries to skip (default = 0) """ - result = self._call_api( + result = self.call_api( "API_GetAchievementUnlocks.php?", {"a": achievement, "c": count, "o": offset}, ).json() @@ -616,7 +562,7 @@ def get_comments( ) if sort not in ["submitted", "-submitted"]: raise ValueError("Invalid sort order. Must be 'submitted' or '-submitted'.") - result = self._call_api( + result = self.call_api( "API_GetComments.php?", {"i": game, "t": target, "c": count, "o": offset, "sort": sort}, ).json() @@ -650,7 +596,7 @@ def get_recent_game_awards( raise ValueError( "Invalid kind value. Must be one of 'beaten-softcore', 'beaten-hardcore', 'completed', or 'mastered'." ) - result = self._call_api( + result = self.call_api( "API_GetRecentGameAwards.php?", {"d": date, "o": offset, "c": count, "k": kind}, ).json() @@ -663,7 +609,7 @@ def get_active_claims(self) -> GetSetClaimsResponse: Params: None """ - result = self._call_api("API_GetActiveClaims.php?", {}).json() + result = self.call_api("API_GetActiveClaims.php?", {}).json() return result def get_inactive_claims(self, kind: int = 1) -> GetSetClaimsResponse: @@ -677,7 +623,7 @@ def get_inactive_claims(self, kind: int = 1) -> GetSetClaimsResponse: raise ValueError( "Invalid kind value. Must be 1 (completed), 2 (dropped) or 3 (expired)." ) - result = self._call_api("API_GetInactiveClaims.php?", {"k": kind}).json() + result = self.call_api("API_GetInactiveClaims.php?", {"k": kind}).json() return result def get_top_ten_users(self) -> GetTopTenUsersResponse: @@ -687,7 +633,7 @@ def get_top_ten_users(self) -> GetTopTenUsersResponse: Params: None """ - result = self._call_api("API_GetTopTenUsers.php?", {}).json() + result = self.call_api("API_GetTopTenUsers.php?", {}).json() return result # Event Endpoints @@ -699,7 +645,7 @@ def get_achievement_of_the_week(self) -> GetAchievementOfTheWeekResponse: Params: None """ - result = self._call_api("API_GetAchievementOfTheWeek.php?", {}).json() + result = self.call_api("API_GetAchievementOfTheWeek.php?", {}).json() return result # Ticket Endpoints @@ -711,7 +657,7 @@ def get_ticket_by_id(self, ticket_id: int) -> GetTicketByIdResponse: Params: i: The ticket ID to query """ - result = self._call_api("API_GetTicketData.php?", {"i": ticket_id}).json() + result = self.call_api("API_GetTicketData.php?", {"i": ticket_id}).json() return result def get_most_ticketed_games(self, focus: int = 1) -> GetMostTicketedGamesResponse: @@ -721,7 +667,7 @@ def get_most_ticketed_games(self, focus: int = 1) -> GetMostTicketedGamesRespons Params: f: Must be set to 1. """ - result = self._call_api("API_GetTicketData.php?", {"f": focus}).json() + result = self.call_api("API_GetTicketData.php?", {"f": focus}).json() return result def get_most_recent_tickets( @@ -734,7 +680,7 @@ def get_most_recent_tickets( c: Count, the number of records to return (default = 10, max = 100) o: Offset, the number of entries to skip (default = 0) """ - result = self._call_api( + result = self.call_api( "API_GetTicketData.php?", {"c": count, "o": offset} ).json() return result @@ -754,7 +700,7 @@ def get_game_ticket_stats( raise ValueError( "Invalid focus value. Must be 3 (official) or 5 (unofficial)." ) - result = self._call_api( + result = self.call_api( "API_GetTicketData.php?", {"g": game, "f": focus, "d": depth} ).json() return result @@ -769,7 +715,7 @@ def get_developer_ticket_stats( u: Username or ULID to query i: ULID to query """ - result = self._call_api( + result = self.call_api( "API_GetTicketData.php?", {"u": username, "i": ulid} ).json() return result @@ -783,5 +729,5 @@ def get_achievement_ticket_stats( Params: a: The achievement ID to query """ - result = self._call_api("API_GetTicketData.php?", {"a": achievement}).json() + result = self.call_api("API_GetTicketData.php?", {"a": achievement}).json() return result diff --git a/retroachievements/models/get_achievement_of_the_week.py b/retroachievements/endpoints/get_achievement_of_the_week.py similarity index 100% rename from retroachievements/models/get_achievement_of_the_week.py rename to retroachievements/endpoints/get_achievement_of_the_week.py diff --git a/retroachievements/models/get_achievement_ticket_stats.py b/retroachievements/endpoints/get_achievement_ticket_stats.py similarity index 100% rename from retroachievements/models/get_achievement_ticket_stats.py rename to retroachievements/endpoints/get_achievement_ticket_stats.py diff --git a/retroachievements/models/get_achievement_unlocks.py b/retroachievements/endpoints/get_achievement_unlocks.py similarity index 100% rename from retroachievements/models/get_achievement_unlocks.py rename to retroachievements/endpoints/get_achievement_unlocks.py diff --git a/retroachievements/models/get_comments.py b/retroachievements/endpoints/get_comments.py similarity index 100% rename from retroachievements/models/get_comments.py rename to retroachievements/endpoints/get_comments.py diff --git a/retroachievements/models/get_console_ids.py b/retroachievements/endpoints/get_console_ids.py similarity index 100% rename from retroachievements/models/get_console_ids.py rename to retroachievements/endpoints/get_console_ids.py diff --git a/retroachievements/models/get_developer_ticket_stats.py b/retroachievements/endpoints/get_developer_ticket_stats.py similarity index 100% rename from retroachievements/models/get_developer_ticket_stats.py rename to retroachievements/endpoints/get_developer_ticket_stats.py diff --git a/retroachievements/models/get_game_leaderboards_response.py b/retroachievements/endpoints/get_game_leaderboards_response.py similarity index 100% rename from retroachievements/models/get_game_leaderboards_response.py rename to retroachievements/endpoints/get_game_leaderboards_response.py diff --git a/retroachievements/models/get_game_list.py b/retroachievements/endpoints/get_game_list.py similarity index 100% rename from retroachievements/models/get_game_list.py rename to retroachievements/endpoints/get_game_list.py diff --git a/retroachievements/models/get_game_response.py b/retroachievements/endpoints/get_game_response.py similarity index 100% rename from retroachievements/models/get_game_response.py rename to retroachievements/endpoints/get_game_response.py diff --git a/retroachievements/models/get_game_ticket_stats.py b/retroachievements/endpoints/get_game_ticket_stats.py similarity index 72% rename from retroachievements/models/get_game_ticket_stats.py rename to retroachievements/endpoints/get_game_ticket_stats.py index e6fa623..d5b101a 100644 --- a/retroachievements/models/get_game_ticket_stats.py +++ b/retroachievements/endpoints/get_game_ticket_stats.py @@ -1,6 +1,6 @@ from typing import TypedDict -from retroachievements.models.get_most_recent_tickets import TicketEntity +from retroachievements.endpoints.get_most_recent_tickets import TicketEntity class GetGameTicketStatsResponse(TypedDict): diff --git a/retroachievements/models/get_leaderboard_entries_response.py b/retroachievements/endpoints/get_leaderboard_entries_response.py similarity index 100% rename from retroachievements/models/get_leaderboard_entries_response.py rename to retroachievements/endpoints/get_leaderboard_entries_response.py diff --git a/retroachievements/models/get_most_recent_tickets.py b/retroachievements/endpoints/get_most_recent_tickets.py similarity index 100% rename from retroachievements/models/get_most_recent_tickets.py rename to retroachievements/endpoints/get_most_recent_tickets.py diff --git a/retroachievements/models/get_most_ticketed_games.py b/retroachievements/endpoints/get_most_ticketed_games.py similarity index 100% rename from retroachievements/models/get_most_ticketed_games.py rename to retroachievements/endpoints/get_most_ticketed_games.py diff --git a/retroachievements/models/get_ticket_by_id.py b/retroachievements/endpoints/get_ticket_by_id.py similarity index 100% rename from retroachievements/models/get_ticket_by_id.py rename to retroachievements/endpoints/get_ticket_by_id.py diff --git a/retroachievements/models/get_user_completed_games_response.py b/retroachievements/endpoints/get_user_completed_games_response.py similarity index 100% rename from retroachievements/models/get_user_completed_games_response.py rename to retroachievements/endpoints/get_user_completed_games_response.py diff --git a/retroachievements/models/get_user_game_leaderboards_response.py b/retroachievements/endpoints/get_user_game_leaderboards_response.py similarity index 100% rename from retroachievements/models/get_user_game_leaderboards_response.py rename to retroachievements/endpoints/get_user_game_leaderboards_response.py diff --git a/retroachievements/models/get_user_set_requests_response.py b/retroachievements/endpoints/get_user_set_requests_response.py similarity index 100% rename from retroachievements/models/get_user_set_requests_response.py rename to retroachievements/endpoints/get_user_set_requests_response.py diff --git a/retroachievements/models/get_user_summary_response.py b/retroachievements/endpoints/get_user_summary_response.py similarity index 100% rename from retroachievements/models/get_user_summary_response.py rename to retroachievements/endpoints/get_user_summary_response.py diff --git a/retroachievements/models/get_user_want_to_play_list_response.py b/retroachievements/endpoints/get_user_want_to_play_list_response.py similarity index 100% rename from retroachievements/models/get_user_want_to_play_list_response.py rename to retroachievements/endpoints/get_user_want_to_play_list_response.py diff --git a/retroachievements/models/get_users_following_me_response.py b/retroachievements/endpoints/get_users_following_me_response.py similarity index 100% rename from retroachievements/models/get_users_following_me_response.py rename to retroachievements/endpoints/get_users_following_me_response.py diff --git a/retroachievements/models/get_users_i_follow_response.py b/retroachievements/endpoints/get_users_i_follow_response.py similarity index 100% rename from retroachievements/models/get_users_i_follow_response.py rename to retroachievements/endpoints/get_users_i_follow_response.py diff --git a/retroachievements/endpoints/user/get_user_profile.py b/retroachievements/endpoints/user/get_user_profile.py new file mode 100644 index 0000000..787f5a8 --- /dev/null +++ b/retroachievements/endpoints/user/get_user_profile.py @@ -0,0 +1,33 @@ +from typing import TypedDict + +from retroachievements.base import BaseRAClient + + +class UserProfileResponse(TypedDict): + User: str + UserPic: str + MemberSince: str + RichPresenceMsg: str + LastGameID: int + ContribCount: int + ContribYield: int + TotalPoints: int + TotalSoftcorePoints: int + TotalTruePoints: int + Permissions: int + Untracked: int + ID: int + UserWallActive: int + Motto: str + + +def get_user_profile(self: BaseRAClient, username: str) -> UserProfileResponse: + """ + A call to this method will retrieve summary information about a given user, + targeted by username. + + Params: + username (str): The user for which to retrieve the summary for. + """ + result = self.call_api("API_GetUserProfile.php?", {"u": username}).json() + return result diff --git a/retroachievements/endpoints/user/get_user_recent_achievements.py b/retroachievements/endpoints/user/get_user_recent_achievements.py new file mode 100644 index 0000000..bb34783 --- /dev/null +++ b/retroachievements/endpoints/user/get_user_recent_achievements.py @@ -0,0 +1,42 @@ +from typing import TypedDict + +from retroachievements.base import BaseRAClient + + +class GetUserRecentAchievementsEntity(TypedDict): + Date: str + HardcoreMode: int # 0 or 1 + AchievementID: int + Title: str + Description: str + BadgeName: str + Points: int + Author: str + GameTitle: str + GameIcon: str + GameID: int + ConsoleName: str + BadgeURL: str + GameURL: str + + +GetUserRecentAchievementsResponse = list[GetUserRecentAchievementsEntity] + + +def get_user_recent_achievements( + self: BaseRAClient, username: str, recent_minutes: int | None = 60 +) -> GetUserRecentAchievementsResponse: + """ + A call to this method will retrieve a list of a target user's recently earned + achievements, via their username. By default, it fetches achievements earned in the + last hour. + + Params: + username(str): The user for which to retrieve the recent achievements for. + recent_minutes(int): Defaults to 60. How many minutes back to fetch for the + given user. + """ + result = self.call_api( + "API_GetUserRecentAchievements.php?", {"u": username, "m": recent_minutes} + ).json() + return result diff --git a/retroachievements/types.py b/retroachievements/types.py index e199902..5900169 100644 --- a/retroachievements/types.py +++ b/retroachievements/types.py @@ -14,44 +14,6 @@ ] -class UserProfileResponse(TypedDict): - User: str - UserPic: str - MemberSince: str - RichPresenceMsg: str - LastGameID: int - ContribCount: int - ContribYield: int - TotalPoints: int - TotalSoftcorePoints: int - TotalTruePoints: int - Permissions: int - Untracked: int - ID: int - UserWallActive: int - Motto: str - - -class GetUserRecentAchievementsEntity(TypedDict): - Date: str - HardcoreMode: int # 0 or 1 - AchievementID: int - Title: str - Description: str - BadgeName: str - Points: int - Author: str - GameTitle: str - GameIcon: str - GameID: int - ConsoleName: str - BadgeURL: str - GameURL: str - - -GetUserRecentAchievementsResponse = list[GetUserRecentAchievementsEntity] - - class DatedUserAchievementResponseEntity(TypedDict): Date: str HardcoreMode: str From 765b73335e3bf7ac8d7aab26886bf02833bed8ef Mon Sep 17 00:00:00 2001 From: amine Date: Sun, 20 Jul 2025 22:40:22 +0200 Subject: [PATCH 14/14] refactor: improve and migrate get_achievements_earned_between --- retroachievements/client.py | 32 +++++------ .../user/get_achievements_earned_between.py | 55 +++++++++++++++++++ .../user/get_user_recent_achievements.py | 6 +- retroachievements/types.py | 22 -------- 4 files changed, 74 insertions(+), 41 deletions(-) create mode 100644 retroachievements/endpoints/user/get_achievements_earned_between.py diff --git a/retroachievements/client.py b/retroachievements/client.py index df72e33..1a2d3b2 100644 --- a/retroachievements/client.py +++ b/retroachievements/client.py @@ -51,6 +51,10 @@ from retroachievements.endpoints.get_users_i_follow_response import ( GetUsersIFollowResponse, ) +from retroachievements.endpoints.user.get_achievements_earned_between import ( + DatedUserAchievementResponse, + get_achievements_earned_between, +) from retroachievements.endpoints.user.get_user_profile import ( get_user_profile, ) @@ -58,7 +62,6 @@ get_user_recent_achievements, ) from retroachievements.types import ( - DatedUserAchievementResponse, GetAchievementCountResponse, GetAchievementDistributionResponse, GetGameExtendedResponse, @@ -91,21 +94,18 @@ def get_user_recent_achievements( ): return get_user_recent_achievements(self, username, recent_minutes) - def get_user_achievements_earned_between( - self, user: str, start: int, end: int - ) -> DatedUserAchievementResponse: - """ - Get a user's achievements in a range - - Params: - u: Username or ULID to query - f: Epoch timestamp. Time range start. - t: Epoch timestamp. Time range end. - """ - result = self.call_api( - "API_GetAchievementsEarnedBetween.php?", {"u": user, "s": start, "e": end} - ).json() - return result + def get_achievements_earned_between( + self: BaseRAClient, + username: str, + from_datetime: datetime.datetime, + to_datetime: datetime.datetime, + ): + return get_achievements_earned_between( + self, + username, + from_datetime, + to_datetime, + ) def get_user_achievements_earned_on_day( self, user: str, date: str diff --git a/retroachievements/endpoints/user/get_achievements_earned_between.py b/retroachievements/endpoints/user/get_achievements_earned_between.py new file mode 100644 index 0000000..a95f157 --- /dev/null +++ b/retroachievements/endpoints/user/get_achievements_earned_between.py @@ -0,0 +1,55 @@ +import datetime +from typing import TypedDict + +from retroachievements.base import BaseRAClient +from retroachievements.types import AchievementType + + +class DatedUserAchievementResponseEntity(TypedDict): + Date: str + HardcoreMode: str + AchievementID: str + Title: str + Description: str + BadgeName: str + Points: str + Author: str + GameTitle: str + GameIcon: str + GameID: str + ConsoleName: str + CumulScore: int + BadgeURL: str + GameURL: str + Type: AchievementType + + +DatedUserAchievementResponse = list[DatedUserAchievementResponseEntity] + + +def get_achievements_earned_between( + self: BaseRAClient, + username: str, + from_datetime: datetime.datetime, + to_datetime: datetime.datetime, +) -> DatedUserAchievementResponse: + """ + A call to this method will retrieve a list of achievements earned by a given user + between two provided dates. + + Params: + username (str): The user for which to retrieve the list of achievements for. + from_datetime (datetime): A datetime object specifying when the list itself + should begin. + to_datetime (datetime): A datetime object specifying when the list itself * + should end. + """ + result = self.call_api( + "API_GetAchievementsEarnedBetween.php?", + { + "u": username, + "f": int(from_datetime.timestamp()), + "t": int(to_datetime.timestamp()), + }, + ).json() + return result diff --git a/retroachievements/endpoints/user/get_user_recent_achievements.py b/retroachievements/endpoints/user/get_user_recent_achievements.py index bb34783..b3a617f 100644 --- a/retroachievements/endpoints/user/get_user_recent_achievements.py +++ b/retroachievements/endpoints/user/get_user_recent_achievements.py @@ -32,9 +32,9 @@ def get_user_recent_achievements( last hour. Params: - username(str): The user for which to retrieve the recent achievements for. - recent_minutes(int): Defaults to 60. How many minutes back to fetch for the - given user. + username (str): The user for which to retrieve the recent achievements for. + recent_minutes (int): Defaults to 60. How many minutes back to fetch for the + given user. """ result = self.call_api( "API_GetUserRecentAchievements.php?", {"u": username, "m": recent_minutes} diff --git a/retroachievements/types.py b/retroachievements/types.py index 5900169..1d98c19 100644 --- a/retroachievements/types.py +++ b/retroachievements/types.py @@ -14,28 +14,6 @@ ] -class DatedUserAchievementResponseEntity(TypedDict): - Date: str - HardcoreMode: str - AchievementID: str - Title: str - Description: str - BadgeName: str - Points: str - Author: str - GameTitle: str - GameIcon: str - GameID: str - ConsoleName: str - CumulScore: int - BadgeURL: str - GameURL: str - Type: AchievementType - - -DatedUserAchievementResponse = list[DatedUserAchievementResponseEntity] - - class GetUserCompletionProgressResponseEntity(TypedDict): GameID: int Title: str