From 0d77a0729168d39a6304cf1649d07d9761b68eb4 Mon Sep 17 00:00:00 2001 From: Skilly Date: Sun, 6 Sep 2026 15:12:38 +0200 Subject: [PATCH] initial commit --- .env.example | 5 + .gitignore | 190 +- LICENSE | 29 +- README.md | 163 +- container/Dockerfile.test | 20 + container/drivers/identify_apworld.py | 49 + container/drivers/run_test.py | 827 +++++++ container/drivers/verify_companions.py | 65 + notebooks/tester_playground.ipynb | 66 + pyproject.toml | 30 + src/apworld_tester/__init__.py | 55 + src/apworld_tester/cli.py | 101 + src/apworld_tester/container/Dockerfile.test | 20 + .../container/drivers/identify_apworld.py | 49 + .../container/drivers/run_test.py | 827 +++++++ .../container/drivers/verify_companions.py | 65 + src/apworld_tester/core/__init__.py | 0 src/apworld_tester/core/config/__init__.py | 0 src/apworld_tester/core/config/config.py | 131 ++ src/apworld_tester/core/config/paths.py | 125 + .../core/config/run_settings.py | 86 + src/apworld_tester/core/config/secrets.py | 41 + src/apworld_tester/core/display/__init__.py | 0 src/apworld_tester/core/display/progress.py | 108 + src/apworld_tester/core/model/__init__.py | 0 src/apworld_tester/core/model/apworld.py | 120 + src/apworld_tester/core/model/blacklist.py | 30 + src/apworld_tester/core/model/fingerprint.py | 30 + src/apworld_tester/core/model/game.py | 47 + src/apworld_tester/core/model/instant.py | 35 + src/apworld_tester/core/model/modes.py | 22 + src/apworld_tester/core/model/name.py | 42 + src/apworld_tester/core/model/verdict.py | 71 + src/apworld_tester/core/state/__init__.py | 0 src/apworld_tester/core/state/keys.py | 23 + src/apworld_tester/core/state/lock.py | 37 + src/apworld_tester/core/state/scratch.py | 32 + src/apworld_tester/core/state/store.py | 55 + src/apworld_tester/pipeline/__init__.py | 0 src/apworld_tester/pipeline/batch/__init__.py | 0 .../pipeline/batch/apworld_finder.py | 67 + src/apworld_tester/pipeline/batch/batch.py | 119 + .../pipeline/batch/cache_check.py | 120 + .../pipeline/batch/companion_pool.py | 65 + .../pipeline/batch/discontinued.py | 69 + .../pipeline/batch/download_failures.py | 88 + .../pipeline/batch/failed_report.py | 36 + src/apworld_tester/pipeline/batch/fetcher.py | 92 + src/apworld_tester/pipeline/batch/options.py | 48 + .../pipeline/batch/orphaned_records.py | 57 + src/apworld_tester/pipeline/batch/planner.py | 104 + src/apworld_tester/pipeline/batch/record.py | 111 + src/apworld_tester/pipeline/batch/reporter.py | 49 + src/apworld_tester/pipeline/batch/results.py | 95 + src/apworld_tester/pipeline/batch/run.py | 92 + src/apworld_tester/pipeline/batch/runner.py | 86 + src/apworld_tester/pipeline/batch/session.py | 92 + src/apworld_tester/pipeline/batch/version.py | 87 + .../pipeline/batch/work_list.py | 41 + .../pipeline/download/__init__.py | 0 .../pipeline/download/asset_matcher.py | 82 + .../pipeline/download/auth_error.py | 23 + .../pipeline/download/context.py | 58 + .../pipeline/download/download_run.py | 156 ++ .../pipeline/download/downloader.py | 75 + .../pipeline/download/handlers/__init__.py | 0 .../pipeline/download/handlers/base.py | 45 + .../download/handlers/gitea_release.py | 44 + .../pipeline/download/handlers/github_file.py | 58 + .../download/handlers/github_release.py | 81 + .../pipeline/download/handlers/github_repo.py | 115 + .../pipeline/download/handlers/gitlab_file.py | 44 + .../download/handlers/gitlab_release.py | 74 + .../pipeline/download/handlers/plain_file.py | 48 + .../pipeline/download/local_file.py | 83 + .../pipeline/download/release_downloader.py | 179 ++ .../pipeline/download/release_identity.py | 55 + .../pipeline/download/release_picker.py | 151 ++ .../pipeline/download/superseded_assets.py | 58 + .../pipeline/download/unusable_links.py | 56 + .../pipeline/download/update_history.py | 168 ++ .../pipeline/download/url_shapes.py | 98 + .../pipeline/download/walk_back.py | 77 + .../pipeline/download/zip_search.py | 96 + .../pipeline/generation/__init__.py | 0 .../pipeline/generation/command.py | 99 + .../pipeline/generation/companion_check.py | 83 + .../pipeline/generation/identifier.py | 75 + .../pipeline/generation/report.py | 54 + .../pipeline/generation/tester.py | 136 ++ src/apworld_tester/pipeline/sheet/__init__.py | 0 src/apworld_tester/pipeline/sheet/fetcher.py | 62 + src/apworld_tester/pipeline/sheet/reader.py | 98 + .../pipeline/update/__init__.py | 0 .../pipeline/update/download_reporter.py | 37 + .../pipeline/update/stage_error.py | 15 + .../pipeline/update/update_run.py | 277 +++ src/apworld_tester/templates/__init__.py | 1 + src/apworld_tester/templates/config.yaml | 69 + src/archipelago_tester/__init__.py | 5 + src/archipelago_tester/core/__init__.py | 0 .../core/config/__init__.py | 0 src/archipelago_tester/core/config/config.py | 39 + src/archipelago_tester/core/config/paths.py | 134 ++ .../core/config/run_settings.py | 66 + src/archipelago_tester/core/config/secrets.py | 41 + .../core/display/__init__.py | 0 .../core/display/progress.py | 103 + src/archipelago_tester/core/model/__init__.py | 0 src/archipelago_tester/core/model/apworld.py | 120 + .../core/model/blacklist.py | 34 + .../core/model/fingerprint.py | 30 + src/archipelago_tester/core/model/game.py | 47 + src/archipelago_tester/core/model/instant.py | 35 + src/archipelago_tester/core/model/modes.py | 22 + src/archipelago_tester/core/model/name.py | 42 + src/archipelago_tester/core/model/verdict.py | 71 + src/archipelago_tester/core/state/__init__.py | 0 src/archipelago_tester/core/state/keys.py | 23 + src/archipelago_tester/core/state/lock.py | 37 + src/archipelago_tester/core/state/scratch.py | 32 + src/archipelago_tester/core/state/store.py | 55 + src/archipelago_tester/pipeline/__init__.py | 0 .../pipeline/batch/__init__.py | 0 .../pipeline/batch/apworld_finder.py | 67 + .../pipeline/batch/batch.py | 119 + .../pipeline/batch/cache_check.py | 120 + src/archipelago_tester/pipeline/batch/cli.py | 152 ++ .../pipeline/batch/companion_pool.py | 65 + .../pipeline/batch/discontinued.py | 69 + .../pipeline/batch/download_failures.py | 88 + .../pipeline/batch/failed_report.py | 36 + .../pipeline/batch/fetcher.py | 92 + .../pipeline/batch/options.py | 48 + .../pipeline/batch/orphaned_records.py | 57 + .../pipeline/batch/planner.py | 106 + .../pipeline/batch/record.py | 111 + .../pipeline/batch/reporter.py | 49 + .../pipeline/batch/results.py | 69 + .../pipeline/batch/runner.py | 86 + .../pipeline/batch/session.py | 95 + .../pipeline/batch/version.py | 87 + .../pipeline/batch/work_list.py | 41 + .../pipeline/download/__init__.py | 0 .../pipeline/download/asset_matcher.py | 75 + .../pipeline/download/auth_error.py | 23 + .../pipeline/download/context.py | 61 + .../pipeline/download/download_run.py | 156 ++ .../pipeline/download/downloader.py | 75 + .../pipeline/download/handlers/__init__.py | 0 .../pipeline/download/handlers/base.py | 45 + .../download/handlers/gitea_release.py | 44 + .../pipeline/download/handlers/github_file.py | 58 + .../download/handlers/github_release.py | 81 + .../pipeline/download/handlers/github_repo.py | 115 + .../pipeline/download/handlers/gitlab_file.py | 44 + .../download/handlers/gitlab_release.py | 74 + .../pipeline/download/handlers/plain_file.py | 48 + .../pipeline/download/local_file.py | 83 + .../pipeline/download/release_downloader.py | 179 ++ .../pipeline/download/release_identity.py | 55 + .../pipeline/download/release_picker.py | 151 ++ .../pipeline/download/superseded_assets.py | 58 + .../pipeline/download/unusable_links.py | 54 + .../pipeline/download/update_history.py | 168 ++ .../pipeline/download/url_shapes.py | 98 + .../pipeline/download/walk_back.py | 77 + .../pipeline/download/zip_search.py | 96 + .../pipeline/generation/__init__.py | 0 .../pipeline/generation/command.py | 102 + .../pipeline/generation/companion_check.py | 77 + .../pipeline/generation/identifier.py | 70 + .../pipeline/generation/report.py | 54 + .../pipeline/generation/tester.py | 132 ++ .../pipeline/host/__init__.py | 0 .../pipeline/host/custom_worlds.py | 134 ++ .../pipeline/sheet/__init__.py | 0 .../pipeline/sheet/fetcher.py | 62 + .../pipeline/sheet/reader.py | 98 + .../pipeline/update/__init__.py | 0 src/archipelago_tester/pipeline/update/cli.py | 68 + .../pipeline/update/download_reporter.py | 37 + .../pipeline/update/stage_error.py | 15 + .../pipeline/update/update_run.py | 298 +++ uv.lock | 2048 +++++++++++++++++ 185 files changed, 15465 insertions(+), 185 deletions(-) create mode 100644 .env.example create mode 100644 container/Dockerfile.test create mode 100644 container/drivers/identify_apworld.py create mode 100644 container/drivers/run_test.py create mode 100644 container/drivers/verify_companions.py create mode 100644 notebooks/tester_playground.ipynb create mode 100644 pyproject.toml create mode 100644 src/apworld_tester/__init__.py create mode 100644 src/apworld_tester/cli.py create mode 100644 src/apworld_tester/container/Dockerfile.test create mode 100644 src/apworld_tester/container/drivers/identify_apworld.py create mode 100644 src/apworld_tester/container/drivers/run_test.py create mode 100644 src/apworld_tester/container/drivers/verify_companions.py create mode 100644 src/apworld_tester/core/__init__.py create mode 100644 src/apworld_tester/core/config/__init__.py create mode 100644 src/apworld_tester/core/config/config.py create mode 100644 src/apworld_tester/core/config/paths.py create mode 100644 src/apworld_tester/core/config/run_settings.py create mode 100644 src/apworld_tester/core/config/secrets.py create mode 100644 src/apworld_tester/core/display/__init__.py create mode 100644 src/apworld_tester/core/display/progress.py create mode 100644 src/apworld_tester/core/model/__init__.py create mode 100644 src/apworld_tester/core/model/apworld.py create mode 100644 src/apworld_tester/core/model/blacklist.py create mode 100644 src/apworld_tester/core/model/fingerprint.py create mode 100644 src/apworld_tester/core/model/game.py create mode 100644 src/apworld_tester/core/model/instant.py create mode 100644 src/apworld_tester/core/model/modes.py create mode 100644 src/apworld_tester/core/model/name.py create mode 100644 src/apworld_tester/core/model/verdict.py create mode 100644 src/apworld_tester/core/state/__init__.py create mode 100644 src/apworld_tester/core/state/keys.py create mode 100644 src/apworld_tester/core/state/lock.py create mode 100644 src/apworld_tester/core/state/scratch.py create mode 100644 src/apworld_tester/core/state/store.py create mode 100644 src/apworld_tester/pipeline/__init__.py create mode 100644 src/apworld_tester/pipeline/batch/__init__.py create mode 100644 src/apworld_tester/pipeline/batch/apworld_finder.py create mode 100644 src/apworld_tester/pipeline/batch/batch.py create mode 100644 src/apworld_tester/pipeline/batch/cache_check.py create mode 100644 src/apworld_tester/pipeline/batch/companion_pool.py create mode 100644 src/apworld_tester/pipeline/batch/discontinued.py create mode 100644 src/apworld_tester/pipeline/batch/download_failures.py create mode 100644 src/apworld_tester/pipeline/batch/failed_report.py create mode 100644 src/apworld_tester/pipeline/batch/fetcher.py create mode 100644 src/apworld_tester/pipeline/batch/options.py create mode 100644 src/apworld_tester/pipeline/batch/orphaned_records.py create mode 100644 src/apworld_tester/pipeline/batch/planner.py create mode 100644 src/apworld_tester/pipeline/batch/record.py create mode 100644 src/apworld_tester/pipeline/batch/reporter.py create mode 100644 src/apworld_tester/pipeline/batch/results.py create mode 100644 src/apworld_tester/pipeline/batch/run.py create mode 100644 src/apworld_tester/pipeline/batch/runner.py create mode 100644 src/apworld_tester/pipeline/batch/session.py create mode 100644 src/apworld_tester/pipeline/batch/version.py create mode 100644 src/apworld_tester/pipeline/batch/work_list.py create mode 100644 src/apworld_tester/pipeline/download/__init__.py create mode 100644 src/apworld_tester/pipeline/download/asset_matcher.py create mode 100644 src/apworld_tester/pipeline/download/auth_error.py create mode 100644 src/apworld_tester/pipeline/download/context.py create mode 100644 src/apworld_tester/pipeline/download/download_run.py create mode 100644 src/apworld_tester/pipeline/download/downloader.py create mode 100644 src/apworld_tester/pipeline/download/handlers/__init__.py create mode 100644 src/apworld_tester/pipeline/download/handlers/base.py create mode 100644 src/apworld_tester/pipeline/download/handlers/gitea_release.py create mode 100644 src/apworld_tester/pipeline/download/handlers/github_file.py create mode 100644 src/apworld_tester/pipeline/download/handlers/github_release.py create mode 100644 src/apworld_tester/pipeline/download/handlers/github_repo.py create mode 100644 src/apworld_tester/pipeline/download/handlers/gitlab_file.py create mode 100644 src/apworld_tester/pipeline/download/handlers/gitlab_release.py create mode 100644 src/apworld_tester/pipeline/download/handlers/plain_file.py create mode 100644 src/apworld_tester/pipeline/download/local_file.py create mode 100644 src/apworld_tester/pipeline/download/release_downloader.py create mode 100644 src/apworld_tester/pipeline/download/release_identity.py create mode 100644 src/apworld_tester/pipeline/download/release_picker.py create mode 100644 src/apworld_tester/pipeline/download/superseded_assets.py create mode 100644 src/apworld_tester/pipeline/download/unusable_links.py create mode 100644 src/apworld_tester/pipeline/download/update_history.py create mode 100644 src/apworld_tester/pipeline/download/url_shapes.py create mode 100644 src/apworld_tester/pipeline/download/walk_back.py create mode 100644 src/apworld_tester/pipeline/download/zip_search.py create mode 100644 src/apworld_tester/pipeline/generation/__init__.py create mode 100644 src/apworld_tester/pipeline/generation/command.py create mode 100644 src/apworld_tester/pipeline/generation/companion_check.py create mode 100644 src/apworld_tester/pipeline/generation/identifier.py create mode 100644 src/apworld_tester/pipeline/generation/report.py create mode 100644 src/apworld_tester/pipeline/generation/tester.py create mode 100644 src/apworld_tester/pipeline/sheet/__init__.py create mode 100644 src/apworld_tester/pipeline/sheet/fetcher.py create mode 100644 src/apworld_tester/pipeline/sheet/reader.py create mode 100644 src/apworld_tester/pipeline/update/__init__.py create mode 100644 src/apworld_tester/pipeline/update/download_reporter.py create mode 100644 src/apworld_tester/pipeline/update/stage_error.py create mode 100644 src/apworld_tester/pipeline/update/update_run.py create mode 100644 src/apworld_tester/templates/__init__.py create mode 100644 src/apworld_tester/templates/config.yaml create mode 100644 src/archipelago_tester/__init__.py create mode 100644 src/archipelago_tester/core/__init__.py create mode 100644 src/archipelago_tester/core/config/__init__.py create mode 100644 src/archipelago_tester/core/config/config.py create mode 100644 src/archipelago_tester/core/config/paths.py create mode 100644 src/archipelago_tester/core/config/run_settings.py create mode 100644 src/archipelago_tester/core/config/secrets.py create mode 100644 src/archipelago_tester/core/display/__init__.py create mode 100644 src/archipelago_tester/core/display/progress.py create mode 100644 src/archipelago_tester/core/model/__init__.py create mode 100644 src/archipelago_tester/core/model/apworld.py create mode 100644 src/archipelago_tester/core/model/blacklist.py create mode 100644 src/archipelago_tester/core/model/fingerprint.py create mode 100644 src/archipelago_tester/core/model/game.py create mode 100644 src/archipelago_tester/core/model/instant.py create mode 100644 src/archipelago_tester/core/model/modes.py create mode 100644 src/archipelago_tester/core/model/name.py create mode 100644 src/archipelago_tester/core/model/verdict.py create mode 100644 src/archipelago_tester/core/state/__init__.py create mode 100644 src/archipelago_tester/core/state/keys.py create mode 100644 src/archipelago_tester/core/state/lock.py create mode 100644 src/archipelago_tester/core/state/scratch.py create mode 100644 src/archipelago_tester/core/state/store.py create mode 100644 src/archipelago_tester/pipeline/__init__.py create mode 100644 src/archipelago_tester/pipeline/batch/__init__.py create mode 100644 src/archipelago_tester/pipeline/batch/apworld_finder.py create mode 100644 src/archipelago_tester/pipeline/batch/batch.py create mode 100644 src/archipelago_tester/pipeline/batch/cache_check.py create mode 100644 src/archipelago_tester/pipeline/batch/cli.py create mode 100644 src/archipelago_tester/pipeline/batch/companion_pool.py create mode 100644 src/archipelago_tester/pipeline/batch/discontinued.py create mode 100644 src/archipelago_tester/pipeline/batch/download_failures.py create mode 100644 src/archipelago_tester/pipeline/batch/failed_report.py create mode 100644 src/archipelago_tester/pipeline/batch/fetcher.py create mode 100644 src/archipelago_tester/pipeline/batch/options.py create mode 100644 src/archipelago_tester/pipeline/batch/orphaned_records.py create mode 100644 src/archipelago_tester/pipeline/batch/planner.py create mode 100644 src/archipelago_tester/pipeline/batch/record.py create mode 100644 src/archipelago_tester/pipeline/batch/reporter.py create mode 100644 src/archipelago_tester/pipeline/batch/results.py create mode 100644 src/archipelago_tester/pipeline/batch/runner.py create mode 100644 src/archipelago_tester/pipeline/batch/session.py create mode 100644 src/archipelago_tester/pipeline/batch/version.py create mode 100644 src/archipelago_tester/pipeline/batch/work_list.py create mode 100644 src/archipelago_tester/pipeline/download/__init__.py create mode 100644 src/archipelago_tester/pipeline/download/asset_matcher.py create mode 100644 src/archipelago_tester/pipeline/download/auth_error.py create mode 100644 src/archipelago_tester/pipeline/download/context.py create mode 100644 src/archipelago_tester/pipeline/download/download_run.py create mode 100644 src/archipelago_tester/pipeline/download/downloader.py create mode 100644 src/archipelago_tester/pipeline/download/handlers/__init__.py create mode 100644 src/archipelago_tester/pipeline/download/handlers/base.py create mode 100644 src/archipelago_tester/pipeline/download/handlers/gitea_release.py create mode 100644 src/archipelago_tester/pipeline/download/handlers/github_file.py create mode 100644 src/archipelago_tester/pipeline/download/handlers/github_release.py create mode 100644 src/archipelago_tester/pipeline/download/handlers/github_repo.py create mode 100644 src/archipelago_tester/pipeline/download/handlers/gitlab_file.py create mode 100644 src/archipelago_tester/pipeline/download/handlers/gitlab_release.py create mode 100644 src/archipelago_tester/pipeline/download/handlers/plain_file.py create mode 100644 src/archipelago_tester/pipeline/download/local_file.py create mode 100644 src/archipelago_tester/pipeline/download/release_downloader.py create mode 100644 src/archipelago_tester/pipeline/download/release_identity.py create mode 100644 src/archipelago_tester/pipeline/download/release_picker.py create mode 100644 src/archipelago_tester/pipeline/download/superseded_assets.py create mode 100644 src/archipelago_tester/pipeline/download/unusable_links.py create mode 100644 src/archipelago_tester/pipeline/download/update_history.py create mode 100644 src/archipelago_tester/pipeline/download/url_shapes.py create mode 100644 src/archipelago_tester/pipeline/download/walk_back.py create mode 100644 src/archipelago_tester/pipeline/download/zip_search.py create mode 100644 src/archipelago_tester/pipeline/generation/__init__.py create mode 100644 src/archipelago_tester/pipeline/generation/command.py create mode 100644 src/archipelago_tester/pipeline/generation/companion_check.py create mode 100644 src/archipelago_tester/pipeline/generation/identifier.py create mode 100644 src/archipelago_tester/pipeline/generation/report.py create mode 100644 src/archipelago_tester/pipeline/generation/tester.py create mode 100644 src/archipelago_tester/pipeline/host/__init__.py create mode 100644 src/archipelago_tester/pipeline/host/custom_worlds.py create mode 100644 src/archipelago_tester/pipeline/sheet/__init__.py create mode 100644 src/archipelago_tester/pipeline/sheet/fetcher.py create mode 100644 src/archipelago_tester/pipeline/sheet/reader.py create mode 100644 src/archipelago_tester/pipeline/update/__init__.py create mode 100644 src/archipelago_tester/pipeline/update/cli.py create mode 100644 src/archipelago_tester/pipeline/update/download_reporter.py create mode 100644 src/archipelago_tester/pipeline/update/stage_error.py create mode 100644 src/archipelago_tester/pipeline/update/update_run.py create mode 100644 uv.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b18e5c1 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Copy to .env and fill in. .env is gitignored; this file is not. +# +# A GitHub personal access token, needing no scopes at all - it is only +# used to raise the API rate limit from 60 requests an hour to 5000. +GITHUB_TOKEN= diff --git a/.gitignore b/.gitignore index 36b13f1..36df193 100644 --- a/.gitignore +++ b/.gitignore @@ -1,176 +1,26 @@ -# ---> Python -# Byte-compiled / optimized / DLL files __pycache__/ -*.py[cod] -*$py.class +*.pyc -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments +# Credentials. Never committed - see .env.example. .env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ -# Spyder project settings -.spyderproject -.spyproject +# Nothing a run generates lives here any more: config.yaml's "data_root" +# points outside the checkout (the Archipelago clone, downloads, seeds +# and state.json all live there). These remain only to catch a config +# that points data_root back inside it. +state.json +scratch_state.json +pipeline.lock +downloads/ +test_output/ +archipelago_src/ +playable_worlds.html +failed_apworlds.txt -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc +# Claude file +CLAUDE.md +# Build output. +build/ +dist/ +*.egg-info/ diff --git a/LICENSE b/LICENSE index d17c1fd..d85a5de 100644 --- a/LICENSE +++ b/LICENSE @@ -1,18 +1,21 @@ MIT License -Copyright (c) 2026 pascal +Copyright (c) 2026 Pascal -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and -associated documentation files (the "Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the -following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial -portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT -LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 9a7f5e5..fe866ce 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,162 @@ -# APWorldTester +# Archipelago World Tester + +This project checks whether community Archipelago worlds can generate a seed. + +It reads the community worlds spreadsheet, downloads each world's newest `.apworld` file, and runs Archipelago's generator against it in a Docker container. + +Each world is tested five ways: + +- by itself +- with two players of the same game +- with 2–5 of Archipelago's built-in worlds +- by itself with its options randomized +- with other worlds and its options randomized + +Each test is repeated with different seeds. The results are combined into one of six stability values: + +| Value | Meaning | +| --- | --- | +| Stable | Every attempt succeeded | +| Minor issues | Solo and multiworld tests worked, but a randomized test failed | +| Flaky | Some attempts succeeded and others failed | +| Solo only | Works by itself, but fails with other worlds | +| Broken | Could not generate at all | +| Unknown | No usable result, for example because the `.apworld` was missing or a ROM was required | + +A successful test means the world loaded, generated a seed, and computed the spoiler log at the configured level. With the default `playthrough` level, this also checks that the seed is completable. + +## Requirements + +- **Python 3.10 or newer** +- **Git** — used to clone Archipelago and check out the version being tested +- **Docker** — must be usable by your user without `sudo` +- **A GitHub token** — required to access the GitHub API. It does not need any scopes +- **Base ROMs** — optional; some worlds cannot generate without them + +## Installation + +### System packages + +On Ubuntu or Debian: + +```sh +sudo apt update +sudo apt install -y git python3 python3-venv +``` + +Install Docker: + +```sh +curl -fsSL https://get.docker.com | sudo sh +sudo usermod -aG docker "$USER" +newgrp docker +docker run --rm hello-world +``` + +The last command should work without `sudo`. + +### Install the package + +```sh +git clone archipelago-world-tester +cd archipelago-world-tester +``` + +Using `uv`: + +```sh +uv sync +``` + +Or using `pip`: + +```sh +python3 -m venv .venv +source .venv/bin/activate +pip install -e . +``` + +### Configuration + +Write a starter config and edit the paths in it: + +```bash +apworld-tester init +``` + +That writes `~/.config/apworld-tester/config.yaml` (or +`$XDG_CONFIG_HOME/apworld-tester/config.yaml`) and prints the path. +Set `general.output_directory` - everything a run generates lives there, +including the Archipelago checkout and the downloaded apworlds - and +`general.roms_directory` for the worlds that need a base ROM. + +The config is looked for in this order, first hit wins: + +1. `$APWORLD_TESTER_CONFIG` +2. `~/.config/apworld-tester/config.yaml` +3. `config.yaml` beside a source checkout + +`apworld-tester config-path` prints which one is in use, or lists +every location it tried. + +Create `.env` with your GitHub token: + +```text +GITHUB_TOKEN=ghp_... +``` + +## Test worlds + +Use `TestRun` to test one or more worlds: + +```python +from apworld_tester import TestRun + +TestRun("Baba Is You").run() +``` + +Multiple worlds: + +```python +TestRun(["Baba Is You", "Anodyne"]).run() +``` + +To test all worlds that have already been downloaded: + +```python +TestRun(cached=True).run() +``` + +The first run clones Archipelago and builds the Docker image, which can take several minutes. + +## Test all worlds + +`UpdateRun` updates Archipelago, refreshes the community worlds spreadsheet, downloads changed worlds, and tests everything: + +```python +from apworld_tester import UpdateRun + +UpdateRun().run() +``` + +## Test a local `.apworld` + +For a world you are developing, put the `.apworld` in its own directory: + +```text +~/my-apworlds/ +└── My Game/ + └── my_game.apworld +``` + +Then run: + +```python +from apworld_tester import TestRun + +TestRun( + "My Game", + root_directory="/home/you/my-apworlds", +).run() +``` -Test Suite for Archipelago Worlds \ No newline at end of file diff --git a/container/Dockerfile.test b/container/Dockerfile.test new file mode 100644 index 0000000..d4c1cd0 --- /dev/null +++ b/container/Dockerfile.test @@ -0,0 +1,20 @@ +# Unprivileged wrapper for the generation tests. +# +# Upstream's image runs as root and leaves /.local root-owned, so running +# it as the host user fails before generation starts (PermissionError on +# /.local) and running it as root leaves root-owned seeds in the mounted +# output directory. Two lines fix both, and they are the only difference +# from the base image. +# +# Built once per Archipelago tag, not per apworld test, so the cost of +# the recursive chown is paid once and amortised over a few hundred +# generations. +ARG BASE_IMAGE=archipelago:latest +FROM ${BASE_IMAGE} + +ARG RUNNER_UID=1000 +ARG RUNNER_GID=1000 + +RUN chown -R ${RUNNER_UID}:${RUNNER_GID} /app /.local 2>/dev/null || chown -R ${RUNNER_UID}:${RUNNER_GID} /app + +USER ${RUNNER_UID}:${RUNNER_GID} diff --git a/container/drivers/identify_apworld.py b/container/drivers/identify_apworld.py new file mode 100644 index 0000000..918c5fb --- /dev/null +++ b/container/drivers/identify_apworld.py @@ -0,0 +1,49 @@ +"""Runs inside the archipelago Docker image. Reports which game (if any) +a single mounted .apworld registers itself as under Archipelago's own +World registry - the import/registration step only, no seed generation. + +Used to verify a freshly downloaded .apworld's actual content matches +the sheet row that fetched it, rather than trusting release/asset +name-matching alone (see AssetMatcher): the filename +and release title are just text an author chose, but AutoWorldRegister +is filled from the World class's own "game" attribute, which is what +Archipelago itself will treat this file as - the authoritative source. +""" + +import argparse +import json +import sys +import warnings + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apworld", required=True) + args = parser.parse_args() + + warnings.simplefilter("ignore") + + try: + import worlds + from worlds.AutoWorld import AutoWorldRegister + except BaseException as error: + # The apworld (or something it imports) crashed outright - that's + # itself a meaningful signal (this file is broken/wrong), not an + # infrastructure failure, so it's still reported as a clean JSON + # result rather than a non-zero exit / stack trace. + print(json.dumps({"games": [], "detail": f"{type(error).__name__}: {error}"})) + return 0 + + games = [ + name for name, world_type in AutoWorldRegister.world_types.items() + if str(getattr(world_type, "zip_path", "") or "") == args.apworld + ] + print(json.dumps({ + "games": games, + "detail": None if games else f"failed_world_loads={worlds.failed_world_loads}", + })) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/container/drivers/run_test.py b/container/drivers/run_test.py new file mode 100644 index 0000000..350825d --- /dev/null +++ b/container/drivers/run_test.py @@ -0,0 +1,827 @@ +"""Runs inside the archipelago Docker image. Generates one seed for a +single installed apworld and reports the outcome as one JSON line on +stdout: passed, needs_input (missing base ROM or similar), or failed. + +The logic mirrors Archipelago's own test/hosting/generate.py, but is +copied here rather than imported from it: the project's .dockerignore +excludes test/ from the image, so that module isn't present at runtime. +""" + +import argparse +import io +import json +import logging +import random +import re +import shutil +import sys +import unicodedata +import time +import warnings +from multiprocessing import Manager, Process, set_start_method +from pathlib import Path + +def ensure_tutorials_default(): + # Monkeypatch applied AFTER whichever Archipelago version's own real + # worlds/AutoWorld.py has already loaded normally - defaults + # WebWorld.tutorials to [] if that core doesn't already declare it, + # without needing to know or duplicate anything else about that + # core's own AutoWorld.py content. Previously this was a full-file + # bind-mount replacement (container/AutoWorld.py (absent - see config.py)) built against one + # specific version's source; that broke outright when tested against + # an older Archipelago release whose real AutoWorld.py imports + # differently (e.g. no rule_builder module before it was added + # upstream) - replacing the whole file assumes it stays + # source-compatible with everything else that release's core + # expects, which isn't true across versions. This achieves the same + # fix (see the original container/AutoWorld.py (absent - see config.py) comment for why it's + # needed - WebHost.py's invalid_worlds filter vs. + # network_data_package snapshot timing) without that assumption. + from worlds.AutoWorld import WebWorld + if "tutorials" not in vars(WebWorld): + WebWorld.tutorials = [] + + +class WebHostIncompatibleError(RuntimeError): + """Generation succeeds, but this world would crash WebHost's own + startup - a bug class Generate.py never exercises (see + check_webhost_compatibility below).""" + + +def check_webhost_compatibility(game): + # Reproduces WebHost's own generate_yaml_templates() (Options.py), + # scoped to just this one world, so a bug like Option.visibility + # being set to a bare int instead of a Visibility flag - which + # crashes generate_yaml_templates() for the ENTIRE deployed site, + # not just the offending world, since it iterates every visible + # world in one loop - is caught here as a normal test failure + # instead of only in production. (A hand-run diagnostic that located + # the offending worlds used to live in container/tools/; removed.) + # which found this bug class live on the deployed instance. + from inspect import cleandoc + + import yaml + from jinja2 import Template + + import Options + from Utils import local_path, __version__ + from worlds.AutoWorld import AutoWorldRegister + + world = AutoWorldRegister.world_types[game] + if not hasattr(world.web, "tutorials"): + # WebHost.py strips any world missing this from AutoWorldRegister + # entirely at startup (its invalid_worlds set) - so it can't + # crash generate_yaml_templates (never part of that loop), but + # the world is now gone from WebHost's registry altogether. That + # breaks something worse than templates: MultiServer can't find + # the game's data (item_name_groups etc.) when starting a room, + # so *hosting* any seed generated with this world crashes with + # a KeyError - even though generation itself succeeded fine. + raise WebHostIncompatibleError( + "passes generation but has no WebWorld.tutorials - WebHost " + "strips it from its world registry entirely at startup, so " + "hosting a room for a seed generated with it crashes " + "MultiServer with a KeyError on the game name" + ) + + def dictify_range(option, option_val): + data = {option_val: 50} + for sub_option in ["random", "random-low", "random-high", + f"random-range-{option.range_start}-{option.range_end}"]: + if sub_option != option_val: + data[sub_option] = 0 + notes = {} + for name, number in getattr(option, "special_range_names", {}).items(): + notes[name] = f"equivalent to {number}" + if number in data: + data[name] = data[number] + del data[number] + elif name in data: + pass + else: + data[name] = 0 + return data, notes + + def yaml_dump_scalar(scalar): + return yaml.dump(scalar).replace("...\n", "").strip() + + with open(local_path("data", "options.yaml")) as f: + template = Template(f.read()) + + try: + option_groups = Options.get_option_groups(world) + presets = world.web.options_presets.copy() + presets.update({"": {}}) + for name, preset in presets.items(): + template.render( + option_groups=option_groups, + __version__=__version__, + game=game, + world_version=world.world_version.as_simple_string(), + yaml_dump=yaml_dump_scalar, + dictify_range=dictify_range, + cleandoc=cleandoc, + preset_name=name, + preset=preset, + ) + except Exception as error: + raise WebHostIncompatibleError( + f"passes generation but would crash WebHost's own startup " + f"(generate_yaml_templates): {type(error).__name__}: {error}" + ) from error + + +def core_worlds(exclude): + """Every world Archipelago ships, minus the one under test. + + Core worlds are the safe companions: they are part of the build + itself, so they are always present and always match the running + version - unlike an apworld, which might be missing or broken for + reasons that have nothing to do with the world being tested. + """ + from worlds.AutoWorld import AutoWorldRegister + return sorted( + name for name, world_type in AutoWorldRegister.world_types.items() + if not str(getattr(world_type, "zip_path", "") or "") and name != exclude + ) + + +def random_options(game): + """One concrete random value per option of `game`. + + Only the option types with a defined value space are randomized - + toggles, choices and ranges. Free text, option lists/sets and item + dicts (start inventory, plando, exclusions...) have no meaningful + "random", and forcing one would fail generation for reasons that have + nothing to do with the world being tested. + + The values are RESOLVED here rather than written into the yaml as the + string "random". Both randomized modes then run the exact same option + values, so a difference between them is attributable to the extra + players and nothing else - writing "random" would have each mode roll + its own values from its own seed, and SR-passes-while-MR-fails would + say nothing about the multiworld. + + Resolution goes through the option's own from_text("random"), so the + values are exactly the ones Archipelago itself would roll: weighted + ranges, named-range specials and all. + """ + from worlds.AutoWorld import AutoWorldRegister + import Options + + randomizable = (Options.Toggle, Options.Choice, Options.Range) + world_type = AutoWorldRegister.world_types[game] + chosen = {} + for name, option in getattr(world_type.options_dataclass, "type_hints", {}).items(): + if not issubclass(option, randomizable): + continue + try: + chosen[name] = option.from_text("random").value + except Exception: + # A world can define an option whose randomization raises; + # leaving it out means it keeps its default rather than + # failing the whole mode for one unrollable option. + continue + return chosen + + +def generate_one(games, dest, results, options_per_game=None, seed=None, spoiler=3): + # games is the full player list: the world under test first, then any + # companions (see RunSettings). One entry is the single-player + # test, the same game twice is the duplicate test, and the world plus + # companions is the multi-game test - all the same code path, differing + # only in who is in the seed and what options they roll. + warnings.simplefilter("ignore") + try: + from tempfile import TemporaryDirectory + + import Generate + import Main + + ensure_tutorials_default() + + with TemporaryDirectory() as players_dir, TemporaryDirectory() as output_dir: + for number, game in enumerate(games, start=1): + player_path = Path(players_dir) / f"{number}.yaml" + # {} means every option at its default - the only point in + # option space the pipeline used to cover. + game_options = (options_per_game or {}).get(game, {}) + player_path.write_text(json.dumps({ + "name": f"Tester{number}", + "game": game, + game: game_options, + "description": f"ArchiUpdater compatibility test: {game}", + }), encoding="utf-8") + + sys.argv = [ + sys.argv[0], + "--player_files_path", players_dir, + "--outputpath", output_dir, + # Spoiler 3 computes the full playthrough, which exercises + # accessibility/reachability logic that plain generation + # never reaches - "a seed came out" becomes "the seed is + # actually completable". + "--spoiler", str(spoiler), + ] + if seed is not None: + sys.argv += ["--seed", str(seed)] + Main.main(*Generate.main()) + check_webhost_compatibility(games[0]) + + output_files = list(Path(output_dir).glob("*.zip")) + if len(output_files) != 1: + raise RuntimeError( + f"expected exactly one output file, found {len(output_files)}") + final_file = Path(dest) / output_files[0].name + # rename() can't cross the tmpdir -> bind-mounted output volume + # boundary, so move (copy + delete) instead. + shutil.move(str(output_files[0]), str(final_file)) + results.append(str(final_file)) + except BaseException as error: + results.append(error) + raise + + +def describe_load_failure(log_text, apworld_path): + """The real reason an apworld did not load, out of the swallowed log. + + worlds/__init__.py catches an import failure per world, formats the + traceback, and hands it to logging.exception - so by default it goes + nowhere and the pipeline could only report "found 0 worlds", which + says nothing an author could act on. Untitled Goose Game, for + instance, defines `option_random` on a Choice, which Archipelago + reserves; the assert fires at class-definition time during import. + + Returns (summary, traceback) - summary being the exception and the + line in the world's own code that raised it. + """ + marker = "Could not load world" + TRACEBACK_HEADER = "Traceback (most recent call last):" + blocks = [block for block in log_text.split(marker) if apworld_path in block] + if not blocks: + return None, None + block = marker + blocks[-1] + lines = [line for line in block.splitlines() if line.strip()] + + # The exception itself is the last line of a traceback. + exception = next((line.strip() for line in reversed(lines) + if line.strip() and not line.startswith((" ", "\t")) + and "Traceback" not in line and marker not in line), None) + + # The deepest frame inside the apworld itself - core frames above it + # are just the import machinery and tell an author nothing. + where = None + for line in lines: + match = re.search(r'File "([^"]*%s[^"]*)", line (\d+)' % re.escape(apworld_path), line) + if match: + inside = match.group(1).split(apworld_path + "/", 1)[-1] + where = f"{inside}, line {match.group(2)}" + + summary = exception or "import failed" + if where: + summary += f" (in {where})" + + # logging.exception both formats the traceback into the message AND + # appends exc_info's own copy, so the block holds it twice - keep one. + first = block.find(TRACEBACK_HEADER) + if first != -1: + second = block.find(TRACEBACK_HEADER, first + len(TRACEBACK_HEADER)) + if second != -1: + block = block[:second] + + # Drop the import machinery. Every one of these frames is identical + # for every failing world and tells an author nothing; what is left is + # their own code and the line that actually raised. + # A frame is its File line plus the indented source and caret lines + # under it, so drop the whole frame - removing only the File line + # leaves orphaned source fragments behind. + noise = (" 1: + detail = (f"this file registers {len(custom_games)} worlds, not one: " + f"{', '.join(custom_games)}. Only a single-world apworld can be " + f"promoted, since the pipeline cannot tell which one this row means.") + elif summary: + detail = f"the world failed to import: {summary}" + if traceback_text: + detail += f"\n\n{traceback_text.strip()}" + else: + # No captured traceback: the file is not a loadable apworld at + # all (not a zip, wrong layout), so nothing was even attempted. + detail = (f"no world loaded from this file and no import was attempted - it is " + f"probably not a valid .apworld (failed_world_loads=" + f"{worlds.failed_world_loads})") + print(json.dumps({ + "game": None, + "outcome": "unverified", + "detail": detail, + "elapsed_seconds": 0, + })) + return 1 + + game = custom_games[0] + + Path(args.output_dir).mkdir(parents=True, exist_ok=True) + + def run_mode(games, options_per_game=None, seed=None, single_player=None, randomized=False): + if single_player is None: + single_player = len(games) == 1 + mode_results, timed_out, elapsed = run_with_timeout( + games, args.output_dir, args.timeout, + options_per_game=options_per_game, seed=seed, spoiler=args.spoiler) + outcome, detail = classify(mode_results, timed_out, elapsed, + single_player=single_player, randomized=randomized) + return {"outcome": outcome, "detail": detail, "elapsed_seconds": round(elapsed, 1), + "timed_out": timed_out} + + # A timeout anywhere abandons the rest of this apworld. One world that + # hangs would otherwise cost timeout x repeats x modes - by far the + # largest thing in a run - and a world that cannot finish one seed has + # nothing useful to say about its stability across ten. + state = {"timed_out": False} + + def skipped(reason): + return {"outcome": "unknown", "detail": reason, "elapsed_seconds": None} + + def run_repeated(players, repeats, single_player=None, options=None, randomized=False): + """The same test several times, reported as a pass rate. + + Stability is not something a single generation can tell you: S, D + and M each get a different Archipelago seed, so the fill differs + every time, and a world that places items successfully four times + in five is genuinely unstable. Running once records whichever + result we happened to get. + + Each repeat uses an explicit seed so a failure among ten passes is + reproducible - without it the interesting case is the one that + cannot be re-examined. + """ + attempts = [] + for _ in range(max(repeats, 1)): + seed = random.randint(0, 2 ** 31 - 1) + # players is a callable so the multi-game test can draw a + # different set of companions every attempt - ten runs against + # one fixed pair only ever tests that pair. + games = players() if callable(players) else players + # A callable, so the randomized modes can roll a different + # point in the option space for every attempt - repeating one + # roll would only re-measure the same combination. + per_game = options() if callable(options) else options + result = run_mode(games, options_per_game=per_game, seed=seed, + single_player=single_player, randomized=randomized) + result["seed"] = seed + if len(games) > 1: + result["companions"] = games[1:] + attempts.append(result) + if result["timed_out"]: + state["timed_out"] = True + break + + passed = [a for a in attempts if a["outcome"] == "passed"] + failed = [a for a in attempts if a["outcome"] != "passed"] + if not failed: + outcome, detail = "passed", f"generated {len(passed)}/{len(attempts)} times" + elif not passed: + # Every attempt agreeing on a non-failure reason keeps that + # reason. A world that needs a base ROM reports needs_input + # once and "failed all 10 attempts" ten times over, and + # flattening those to "failed" blames the world for a file + # this pipeline does not have - which matters most for core + # worlds, where roughly seven of them need a ROM and would + # otherwise all read as Broken. + outcomes = {attempt["outcome"] for attempt in attempts} + outcome = outcomes.pop() if len(outcomes) == 1 else "failed" + detail = f"failed all {len(attempts)} attempts. {failed[0]['detail']}" + if outcome != "failed": + detail = (f"{outcome} on all {len(attempts)} attempts. " + f"{failed[0]['detail']}") + else: + # The whole point of repeating: a world that works sometimes + # is worse for a player than one that never does, because + # nobody knows to avoid it. + outcome = "flaky" + detail = (f"generated {len(passed)}/{len(attempts)} times. " + f"First failure (seed {failed[0]['seed']}): {failed[0]['detail']}") + return { + "outcome": outcome, + "detail": detail, + "elapsed_seconds": round(sum(a["elapsed_seconds"] or 0 for a in attempts), 1), + "attempts": len(attempts), + # What was asked for, as opposed to what ran - a timeout cuts + # a mode short, and the retest cache compares against this so + # such a record is not re-run forever. + "requested_attempts": max(repeats, 1), + "passed_attempts": len(passed), + "failed_seeds": [a["seed"] for a in failed], + # Which companions each failing attempt drew, so a failure + # caused by one particular combination can be reproduced. + "failed_companions": [a.get("companions") for a in failed if a.get("companions")], + } + + tests = {} + + # 1. the world on its own, every option at its default. + tests["single"] = run_repeated([game], args.repeats) + tests["single"]["label"] = "Single game" + + # 2. the same world twice. A world that keeps state on the class + # rather than the instance works alone and corrupts itself here - + # a bug class no other mode can see. + tests["duplicate"] = (skipped("skipped - an earlier test timed out") if state["timed_out"] + else run_mode([game, game], single_player=False)) + tests["duplicate"]["label"] = "Two players, same game" + + # 3. the world alongside other games. Each attempt draws a fresh + # random selection of core worlds rather than reusing one fixed + # pair: ten runs against the same two companions only ever tell you + # about those two, while a different combination each time samples + # what a real multiworld looks like. + configured_pool = [name.strip() for name in args.companion_pool.split(",") if name.strip()] + if configured_pool: + # Verified by the host once per run: every one of these generated + # a seed on its own in this exact environment, so a multi-game + # failure is attributable to the world under test rather than to + # a companion that was never going to work. + pool = [name for name in configured_pool if name != game] + else: + pool = core_worlds(exclude=game) + low = max(args.companion_min, 1) + high = max(args.companion_max, low) + + def draw_companions(): + count = min(random.randint(low, high), len(pool)) + return random.sample(pool, count) + + if state["timed_out"]: + tests["multi"] = skipped("skipped - an earlier test timed out") + elif pool: + tests["multi"] = run_repeated( + lambda: [game] + draw_companions(), args.repeats, single_player=False) + tests["multi"]["companion_pool"] = len(pool) + tests["multi"]["companion_range"] = [low, high] + tests["multi"]["detail"] += f" (against {low}-{high} core games drawn from {len(pool)})" + else: + tests["multi"] = skipped("no core worlds available to pair with") + tests["multi"]["label"] = "Multi game" + + # 4 and 5. The same two shapes as above - the world alone, and the + # world alongside the companions - but with its options randomized + # instead of left at their defaults. The default option set is a + # single point in a world's option space and most option-dependent + # crashes are nowhere near it. + # + # One roll each, and every roll that does not produce a seed is a + # failure, including one the world rejects with OptionError. That + # last point is the whole reason these modes exist: the yaml a + # player writes is accepted as valid - nothing in the template or + # the upload path objects to "random" on two interdependent + # options - so the conflict only surfaces when generation is + # actually attempted, possibly after everyone has submitted, and it + # is intermittent. Re-rolling until the world accepts something + # would hide exactly the failure a real player hits. + # + # Each randomized run is gated on its own default-options + # counterpart: SR only when the world generates alone, MR only when + # it generates alongside the companions. A randomized run whose + # plain equivalent already fails would fail for the same underlying + # reason and cost a generation to learn nothing. + rolls = None + if state["timed_out"]: + aborted = "skipped - an earlier test timed out" + tests["single_random"] = skipped(aborted) + tests["multi_random"] = skipped(aborted) + elif tests["single"]["outcome"] == "passed" or tests["multi"]["outcome"] == "passed": + try: + # One roll per attempt, drawn up front so SR and MR run the + # SAME combinations as each other. Pairing them is what makes + # "SR passed but MR failed" attributable to the extra players + # rather than to two different option sets. + rolls = [random_options(game) for _ in range(max(args.random_repeats, 1))] + except BaseException as error: + rolls = None + unreadable = f"could not read this world's options: {type(error).__name__}: {error}" + tests["single_random"] = skipped(unreadable) + tests["multi_random"] = skipped(unreadable) + + if rolls: + def roller(): + # Walks the pre-drawn rolls in order, so attempt N of SR and + # attempt N of MR use the same option values. + supply = iter(rolls) + def next_roll(): + return {game: next(supply)} + return next_roll + + # "flaky" counts as generating: the world produced a seed at + # least once, so a randomized run can still tell us something. + if tests["single"]["outcome"] in ("passed", "flaky"): + result = run_repeated([game], len(rolls), single_player=True, + options=roller(), randomized=True) + result["randomized_options"] = len(rolls[0]) + tests["single_random"] = result + else: + tests["single_random"] = skipped( + "skipped - the single-game test did not pass") + + if tests["multi"]["outcome"] in ("passed", "flaky"): + result = run_repeated(lambda: [game] + draw_companions(), len(rolls), + single_player=False, options=roller(), randomized=True) + result["randomized_options"] = len(rolls[0]) + tests["multi_random"] = result + else: + tests["multi_random"] = skipped( + "skipped - the multi-game test did not pass") + elif "single_random" not in tests: + neither = "skipped - neither the single-game nor the multi-game test passed" + tests["single_random"] = skipped(neither) + tests["multi_random"] = skipped(neither) + + tests["single_random"]["label"] = "Random options, alone" + tests["multi_random"]["label"] = "Random options, multi game" + + # The top-level fields stay exactly what they were - the single-player + # verdict - because that is what promotion and every existing consumer + # reads. The per-mode results live alongside it. + report = { + "game": game, + "outcome": tests["single"]["outcome"], + "detail": tests["single"]["detail"], + "elapsed_seconds": tests["single"]["elapsed_seconds"], + "multi_outcome": tests["multi"]["outcome"], + "multi_detail": tests["multi"]["detail"], + "multi_elapsed_seconds": tests["multi"]["elapsed_seconds"], + "multi_companions": tests["multi"].get("companions", []), + "tests": tests, + } + + print(json.dumps(report)) + # Exit code still reflects the single-player result alone: it is what + # gates promotion, and the other modes are reported rather than + # blocking. + return {"passed": 0, "needs_input": 2}.get(tests["single"]["outcome"], 1) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/container/drivers/verify_companions.py b/container/drivers/verify_companions.py new file mode 100644 index 0000000..df58bd5 --- /dev/null +++ b/container/drivers/verify_companions.py @@ -0,0 +1,65 @@ +"""Runs inside the archipelago Docker image. Reports which core worlds +can actually generate a seed in THIS environment, so the multi-game tests +only ever draw companions that work. + +Core worlds are shipped with Archipelago, but that does not make them all +usable here: some need a base ROM this container has no copy of, and at +least one (Final Fantasy) needs a settings yaml generated by an external +website and refuses a generic one. Drawing such a world as a companion +fails the whole seed, and the failure gets recorded against the apworld +under test rather than the companion that caused it - Blender was marked +flaky 7/10 purely because three of its ten draws included worlds like +these. + +Rather than maintain an exclusion list by hand, this generates one solo +seed per core world and keeps the ones that succeed. A world drops out +by itself when its ROM is missing, and comes back by itself once the ROM +is added. + +Shares run_test.py's generation machinery rather than repeating it, so +"can generate" means exactly the same thing in both places. +""" + +import argparse +import json +import sys +import warnings + +sys.path.insert(0, "/app") + +import run_test # noqa: E402 - mounted alongside this script + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", required=True) + parser.add_argument("--timeout", type=int, default=120, + help="Per-world timeout. Deliberately shorter than the apworld " + "timeout: a companion that is slow to generate would multiply " + "across every multi-game attempt that draws it.") + parser.add_argument("--spoiler", type=int, default=2) + args = parser.parse_args() + + warnings.simplefilter("ignore") + run_test.ensure_tutorials_default() + + from pathlib import Path + Path(args.output_dir).mkdir(parents=True, exist_ok=True) + + verified = [] + rejected = {} + for game in run_test.core_worlds(exclude=None): + results, timed_out, elapsed = run_test.run_with_timeout( + [game], args.output_dir, args.timeout, spoiler=args.spoiler) + outcome, detail = run_test.classify(results, timed_out, elapsed) + if outcome == "passed": + verified.append(game) + else: + rejected[game] = f"{outcome}: {detail}"[:300] + + print(json.dumps({"verified": verified, "rejected": rejected})) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/notebooks/tester_playground.ipynb b/notebooks/tester_playground.ipynb new file mode 100644 index 0000000..46bb684 --- /dev/null +++ b/notebooks/tester_playground.ipynb @@ -0,0 +1,66 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Testing worlds\n", + "\n", + "`TestSession` prints the paths it uses, downloads the apworld if it isn't\n", + "there yet, then runs all five generation modes and prints the verdicts.\n", + "\n", + "Results go to `scratch_state.json` under the data directory, not the live\n", + "`state.json` - pass `live=True` when a result is meant to count.\n", + "\n", + "Needs the package installed: `pip install -e .` from the repository root." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## One world" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "from archipelago_tester.pipeline.batch.session import TestSession\n", + "\n", + "TestSession(\"Baba Is You\").run()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Several worlds" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "TestSession([\"Baba Is You\", \"Anodyne\", \"Axiom Verge\"]).run()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3b4d7f3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "APWorldTester" +version = "0.1.0" +description = "Tests Archipelago community worlds by generating seeds with them" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" +dependencies = [ + "requests", + "beautifulsoup4", + "pyyaml", +] + +[project.scripts] +apworld-tester = "apworld_tester.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +apworld_tester = [ + "templates/*.yaml", + "container/Dockerfile.test", + "container/drivers/*.py", +] diff --git a/src/apworld_tester/__init__.py b/src/apworld_tester/__init__.py new file mode 100644 index 0000000..76e0c8c --- /dev/null +++ b/src/apworld_tester/__init__.py @@ -0,0 +1,55 @@ +"""Tests Archipelago community worlds by generating seeds with them. + +The publishing half - archipelago-world-site - imports this package as a +dependency. Nothing here knows that it exists. + +Everything downstream needs is re-exported here, so callers import from +``apworld_tester`` and never from the module layout underneath it. +""" + +from apworld_tester.core.config.config import Config +from apworld_tester.core.config.paths import Paths +from apworld_tester.core.model.apworld import ApworldFile +from apworld_tester.core.model.blacklist import Blacklist +from apworld_tester.core.model.fingerprint import Fingerprint +from apworld_tester.core.model.game import Game +from apworld_tester.core.model.instant import Instant +from apworld_tester.core.model.modes import Modes +from apworld_tester.core.model.name import Name +from apworld_tester.core.model.verdict import Verdict +from apworld_tester.core.state.keys import StateKeys +from apworld_tester.core.state.lock import LockHeldError, PipelineLock +from apworld_tester.core.state.store import StateStore +from apworld_tester.pipeline.batch.run import TestRun +from apworld_tester.pipeline.build.archipelago import ArchipelagoBuild +from apworld_tester.pipeline.build.docker import Docker +from apworld_tester.pipeline.download.update_history import UpdateHistory +from apworld_tester.pipeline.generation.command import ContainerCommand +from apworld_tester.pipeline.sheet.reader import SheetReader +from apworld_tester.pipeline.update.stage_error import StageError +from apworld_tester.pipeline.update.update_run import UpdateRun + +__all__ = [ + "ApworldFile", + "ArchipelagoBuild", + "Blacklist", + "Config", + "ContainerCommand", + "Docker", + "Fingerprint", + "Game", + "Instant", + "LockHeldError", + "Modes", + "Name", + "Paths", + "PipelineLock", + "SheetReader", + "StageError", + "StateKeys", + "StateStore", + "TestRun", + "UpdateHistory", + "UpdateRun", + "Verdict", +] diff --git a/src/apworld_tester/cli.py b/src/apworld_tester/cli.py new file mode 100644 index 0000000..1706179 --- /dev/null +++ b/src/apworld_tester/cli.py @@ -0,0 +1,101 @@ +"""The command line: getting a config file into place. + +The package is installed rather than cloned, so the starter config +cannot be "the example beside this script" - it is shipped as package +data and copied out on request. +""" + +import argparse +import os +import shutil +import sys + +from importlib import resources + +from apworld_tester.core.config.config import Config, ConfigNotFound + + +class ConfigTemplate: + """The starter config.yaml carried inside the package.""" + + PACKAGE = "apworld_tester.templates" + NAME = "config.yaml" + + @classmethod + def read(cls): + return (resources.files(cls.PACKAGE) / cls.NAME).read_text( + encoding="utf-8") + + +class InitCommand: + """Write a starter config where the search order will find it.""" + + def __init__(self, destination=None, force=False): + self.destination = destination or Config.user_path() + self.force = force + + def run(self): + if os.path.exists(self.destination) and not self.force: + print(f"config already exists: {self.destination}") + print("pass --force to overwrite it") + return 1 + os.makedirs(os.path.dirname(self.destination), exist_ok=True) + if os.path.exists(self.destination): + shutil.copyfile(self.destination, f"{self.destination}.bak") + print(f"kept the old one as {self.destination}.bak") + with open(self.destination, "w", encoding="utf-8") as handle: + handle.write(ConfigTemplate.read()) + print(f"wrote {self.destination}") + print("Edit general.output_directory and general.roms_directory " + "before the first run.") + return 0 + + +class PathCommand: + """Say which config would be used, and what was tried.""" + + def run(self): + try: + print(Config.default_path()) + return 0 + except ConfigNotFound as error: + print(error, file=sys.stderr) + return 1 + + +class Cli: + """One entry point, so an installed copy is self-describing.""" + + @staticmethod + def parser(): + parser = argparse.ArgumentParser( + prog="apworld-tester", description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + init = commands.add_parser( + "init", help="Write a starter config.yaml and say where.") + init.add_argument( + "--path", default=None, + help="Write here instead of the user config directory.") + init.add_argument( + "--force", action="store_true", + help="Overwrite an existing config, keeping a .bak copy.") + commands.add_parser( + "config-path", + help="Print the config that would be used, or where it " + "was looked for.") + return parser + + @classmethod + def main(cls, argv=None): + args = cls.parser().parse_args(argv) + if args.command == "init": + return InitCommand(args.path, args.force).run() + return PathCommand().run() + + +def main(argv=None): + return Cli.main(argv) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/apworld_tester/container/Dockerfile.test b/src/apworld_tester/container/Dockerfile.test new file mode 100644 index 0000000..d4c1cd0 --- /dev/null +++ b/src/apworld_tester/container/Dockerfile.test @@ -0,0 +1,20 @@ +# Unprivileged wrapper for the generation tests. +# +# Upstream's image runs as root and leaves /.local root-owned, so running +# it as the host user fails before generation starts (PermissionError on +# /.local) and running it as root leaves root-owned seeds in the mounted +# output directory. Two lines fix both, and they are the only difference +# from the base image. +# +# Built once per Archipelago tag, not per apworld test, so the cost of +# the recursive chown is paid once and amortised over a few hundred +# generations. +ARG BASE_IMAGE=archipelago:latest +FROM ${BASE_IMAGE} + +ARG RUNNER_UID=1000 +ARG RUNNER_GID=1000 + +RUN chown -R ${RUNNER_UID}:${RUNNER_GID} /app /.local 2>/dev/null || chown -R ${RUNNER_UID}:${RUNNER_GID} /app + +USER ${RUNNER_UID}:${RUNNER_GID} diff --git a/src/apworld_tester/container/drivers/identify_apworld.py b/src/apworld_tester/container/drivers/identify_apworld.py new file mode 100644 index 0000000..918c5fb --- /dev/null +++ b/src/apworld_tester/container/drivers/identify_apworld.py @@ -0,0 +1,49 @@ +"""Runs inside the archipelago Docker image. Reports which game (if any) +a single mounted .apworld registers itself as under Archipelago's own +World registry - the import/registration step only, no seed generation. + +Used to verify a freshly downloaded .apworld's actual content matches +the sheet row that fetched it, rather than trusting release/asset +name-matching alone (see AssetMatcher): the filename +and release title are just text an author chose, but AutoWorldRegister +is filled from the World class's own "game" attribute, which is what +Archipelago itself will treat this file as - the authoritative source. +""" + +import argparse +import json +import sys +import warnings + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apworld", required=True) + args = parser.parse_args() + + warnings.simplefilter("ignore") + + try: + import worlds + from worlds.AutoWorld import AutoWorldRegister + except BaseException as error: + # The apworld (or something it imports) crashed outright - that's + # itself a meaningful signal (this file is broken/wrong), not an + # infrastructure failure, so it's still reported as a clean JSON + # result rather than a non-zero exit / stack trace. + print(json.dumps({"games": [], "detail": f"{type(error).__name__}: {error}"})) + return 0 + + games = [ + name for name, world_type in AutoWorldRegister.world_types.items() + if str(getattr(world_type, "zip_path", "") or "") == args.apworld + ] + print(json.dumps({ + "games": games, + "detail": None if games else f"failed_world_loads={worlds.failed_world_loads}", + })) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/apworld_tester/container/drivers/run_test.py b/src/apworld_tester/container/drivers/run_test.py new file mode 100644 index 0000000..350825d --- /dev/null +++ b/src/apworld_tester/container/drivers/run_test.py @@ -0,0 +1,827 @@ +"""Runs inside the archipelago Docker image. Generates one seed for a +single installed apworld and reports the outcome as one JSON line on +stdout: passed, needs_input (missing base ROM or similar), or failed. + +The logic mirrors Archipelago's own test/hosting/generate.py, but is +copied here rather than imported from it: the project's .dockerignore +excludes test/ from the image, so that module isn't present at runtime. +""" + +import argparse +import io +import json +import logging +import random +import re +import shutil +import sys +import unicodedata +import time +import warnings +from multiprocessing import Manager, Process, set_start_method +from pathlib import Path + +def ensure_tutorials_default(): + # Monkeypatch applied AFTER whichever Archipelago version's own real + # worlds/AutoWorld.py has already loaded normally - defaults + # WebWorld.tutorials to [] if that core doesn't already declare it, + # without needing to know or duplicate anything else about that + # core's own AutoWorld.py content. Previously this was a full-file + # bind-mount replacement (container/AutoWorld.py (absent - see config.py)) built against one + # specific version's source; that broke outright when tested against + # an older Archipelago release whose real AutoWorld.py imports + # differently (e.g. no rule_builder module before it was added + # upstream) - replacing the whole file assumes it stays + # source-compatible with everything else that release's core + # expects, which isn't true across versions. This achieves the same + # fix (see the original container/AutoWorld.py (absent - see config.py) comment for why it's + # needed - WebHost.py's invalid_worlds filter vs. + # network_data_package snapshot timing) without that assumption. + from worlds.AutoWorld import WebWorld + if "tutorials" not in vars(WebWorld): + WebWorld.tutorials = [] + + +class WebHostIncompatibleError(RuntimeError): + """Generation succeeds, but this world would crash WebHost's own + startup - a bug class Generate.py never exercises (see + check_webhost_compatibility below).""" + + +def check_webhost_compatibility(game): + # Reproduces WebHost's own generate_yaml_templates() (Options.py), + # scoped to just this one world, so a bug like Option.visibility + # being set to a bare int instead of a Visibility flag - which + # crashes generate_yaml_templates() for the ENTIRE deployed site, + # not just the offending world, since it iterates every visible + # world in one loop - is caught here as a normal test failure + # instead of only in production. (A hand-run diagnostic that located + # the offending worlds used to live in container/tools/; removed.) + # which found this bug class live on the deployed instance. + from inspect import cleandoc + + import yaml + from jinja2 import Template + + import Options + from Utils import local_path, __version__ + from worlds.AutoWorld import AutoWorldRegister + + world = AutoWorldRegister.world_types[game] + if not hasattr(world.web, "tutorials"): + # WebHost.py strips any world missing this from AutoWorldRegister + # entirely at startup (its invalid_worlds set) - so it can't + # crash generate_yaml_templates (never part of that loop), but + # the world is now gone from WebHost's registry altogether. That + # breaks something worse than templates: MultiServer can't find + # the game's data (item_name_groups etc.) when starting a room, + # so *hosting* any seed generated with this world crashes with + # a KeyError - even though generation itself succeeded fine. + raise WebHostIncompatibleError( + "passes generation but has no WebWorld.tutorials - WebHost " + "strips it from its world registry entirely at startup, so " + "hosting a room for a seed generated with it crashes " + "MultiServer with a KeyError on the game name" + ) + + def dictify_range(option, option_val): + data = {option_val: 50} + for sub_option in ["random", "random-low", "random-high", + f"random-range-{option.range_start}-{option.range_end}"]: + if sub_option != option_val: + data[sub_option] = 0 + notes = {} + for name, number in getattr(option, "special_range_names", {}).items(): + notes[name] = f"equivalent to {number}" + if number in data: + data[name] = data[number] + del data[number] + elif name in data: + pass + else: + data[name] = 0 + return data, notes + + def yaml_dump_scalar(scalar): + return yaml.dump(scalar).replace("...\n", "").strip() + + with open(local_path("data", "options.yaml")) as f: + template = Template(f.read()) + + try: + option_groups = Options.get_option_groups(world) + presets = world.web.options_presets.copy() + presets.update({"": {}}) + for name, preset in presets.items(): + template.render( + option_groups=option_groups, + __version__=__version__, + game=game, + world_version=world.world_version.as_simple_string(), + yaml_dump=yaml_dump_scalar, + dictify_range=dictify_range, + cleandoc=cleandoc, + preset_name=name, + preset=preset, + ) + except Exception as error: + raise WebHostIncompatibleError( + f"passes generation but would crash WebHost's own startup " + f"(generate_yaml_templates): {type(error).__name__}: {error}" + ) from error + + +def core_worlds(exclude): + """Every world Archipelago ships, minus the one under test. + + Core worlds are the safe companions: they are part of the build + itself, so they are always present and always match the running + version - unlike an apworld, which might be missing or broken for + reasons that have nothing to do with the world being tested. + """ + from worlds.AutoWorld import AutoWorldRegister + return sorted( + name for name, world_type in AutoWorldRegister.world_types.items() + if not str(getattr(world_type, "zip_path", "") or "") and name != exclude + ) + + +def random_options(game): + """One concrete random value per option of `game`. + + Only the option types with a defined value space are randomized - + toggles, choices and ranges. Free text, option lists/sets and item + dicts (start inventory, plando, exclusions...) have no meaningful + "random", and forcing one would fail generation for reasons that have + nothing to do with the world being tested. + + The values are RESOLVED here rather than written into the yaml as the + string "random". Both randomized modes then run the exact same option + values, so a difference between them is attributable to the extra + players and nothing else - writing "random" would have each mode roll + its own values from its own seed, and SR-passes-while-MR-fails would + say nothing about the multiworld. + + Resolution goes through the option's own from_text("random"), so the + values are exactly the ones Archipelago itself would roll: weighted + ranges, named-range specials and all. + """ + from worlds.AutoWorld import AutoWorldRegister + import Options + + randomizable = (Options.Toggle, Options.Choice, Options.Range) + world_type = AutoWorldRegister.world_types[game] + chosen = {} + for name, option in getattr(world_type.options_dataclass, "type_hints", {}).items(): + if not issubclass(option, randomizable): + continue + try: + chosen[name] = option.from_text("random").value + except Exception: + # A world can define an option whose randomization raises; + # leaving it out means it keeps its default rather than + # failing the whole mode for one unrollable option. + continue + return chosen + + +def generate_one(games, dest, results, options_per_game=None, seed=None, spoiler=3): + # games is the full player list: the world under test first, then any + # companions (see RunSettings). One entry is the single-player + # test, the same game twice is the duplicate test, and the world plus + # companions is the multi-game test - all the same code path, differing + # only in who is in the seed and what options they roll. + warnings.simplefilter("ignore") + try: + from tempfile import TemporaryDirectory + + import Generate + import Main + + ensure_tutorials_default() + + with TemporaryDirectory() as players_dir, TemporaryDirectory() as output_dir: + for number, game in enumerate(games, start=1): + player_path = Path(players_dir) / f"{number}.yaml" + # {} means every option at its default - the only point in + # option space the pipeline used to cover. + game_options = (options_per_game or {}).get(game, {}) + player_path.write_text(json.dumps({ + "name": f"Tester{number}", + "game": game, + game: game_options, + "description": f"ArchiUpdater compatibility test: {game}", + }), encoding="utf-8") + + sys.argv = [ + sys.argv[0], + "--player_files_path", players_dir, + "--outputpath", output_dir, + # Spoiler 3 computes the full playthrough, which exercises + # accessibility/reachability logic that plain generation + # never reaches - "a seed came out" becomes "the seed is + # actually completable". + "--spoiler", str(spoiler), + ] + if seed is not None: + sys.argv += ["--seed", str(seed)] + Main.main(*Generate.main()) + check_webhost_compatibility(games[0]) + + output_files = list(Path(output_dir).glob("*.zip")) + if len(output_files) != 1: + raise RuntimeError( + f"expected exactly one output file, found {len(output_files)}") + final_file = Path(dest) / output_files[0].name + # rename() can't cross the tmpdir -> bind-mounted output volume + # boundary, so move (copy + delete) instead. + shutil.move(str(output_files[0]), str(final_file)) + results.append(str(final_file)) + except BaseException as error: + results.append(error) + raise + + +def describe_load_failure(log_text, apworld_path): + """The real reason an apworld did not load, out of the swallowed log. + + worlds/__init__.py catches an import failure per world, formats the + traceback, and hands it to logging.exception - so by default it goes + nowhere and the pipeline could only report "found 0 worlds", which + says nothing an author could act on. Untitled Goose Game, for + instance, defines `option_random` on a Choice, which Archipelago + reserves; the assert fires at class-definition time during import. + + Returns (summary, traceback) - summary being the exception and the + line in the world's own code that raised it. + """ + marker = "Could not load world" + TRACEBACK_HEADER = "Traceback (most recent call last):" + blocks = [block for block in log_text.split(marker) if apworld_path in block] + if not blocks: + return None, None + block = marker + blocks[-1] + lines = [line for line in block.splitlines() if line.strip()] + + # The exception itself is the last line of a traceback. + exception = next((line.strip() for line in reversed(lines) + if line.strip() and not line.startswith((" ", "\t")) + and "Traceback" not in line and marker not in line), None) + + # The deepest frame inside the apworld itself - core frames above it + # are just the import machinery and tell an author nothing. + where = None + for line in lines: + match = re.search(r'File "([^"]*%s[^"]*)", line (\d+)' % re.escape(apworld_path), line) + if match: + inside = match.group(1).split(apworld_path + "/", 1)[-1] + where = f"{inside}, line {match.group(2)}" + + summary = exception or "import failed" + if where: + summary += f" (in {where})" + + # logging.exception both formats the traceback into the message AND + # appends exc_info's own copy, so the block holds it twice - keep one. + first = block.find(TRACEBACK_HEADER) + if first != -1: + second = block.find(TRACEBACK_HEADER, first + len(TRACEBACK_HEADER)) + if second != -1: + block = block[:second] + + # Drop the import machinery. Every one of these frames is identical + # for every failing world and tells an author nothing; what is left is + # their own code and the line that actually raised. + # A frame is its File line plus the indented source and caret lines + # under it, so drop the whole frame - removing only the File line + # leaves orphaned source fragments behind. + noise = (" 1: + detail = (f"this file registers {len(custom_games)} worlds, not one: " + f"{', '.join(custom_games)}. Only a single-world apworld can be " + f"promoted, since the pipeline cannot tell which one this row means.") + elif summary: + detail = f"the world failed to import: {summary}" + if traceback_text: + detail += f"\n\n{traceback_text.strip()}" + else: + # No captured traceback: the file is not a loadable apworld at + # all (not a zip, wrong layout), so nothing was even attempted. + detail = (f"no world loaded from this file and no import was attempted - it is " + f"probably not a valid .apworld (failed_world_loads=" + f"{worlds.failed_world_loads})") + print(json.dumps({ + "game": None, + "outcome": "unverified", + "detail": detail, + "elapsed_seconds": 0, + })) + return 1 + + game = custom_games[0] + + Path(args.output_dir).mkdir(parents=True, exist_ok=True) + + def run_mode(games, options_per_game=None, seed=None, single_player=None, randomized=False): + if single_player is None: + single_player = len(games) == 1 + mode_results, timed_out, elapsed = run_with_timeout( + games, args.output_dir, args.timeout, + options_per_game=options_per_game, seed=seed, spoiler=args.spoiler) + outcome, detail = classify(mode_results, timed_out, elapsed, + single_player=single_player, randomized=randomized) + return {"outcome": outcome, "detail": detail, "elapsed_seconds": round(elapsed, 1), + "timed_out": timed_out} + + # A timeout anywhere abandons the rest of this apworld. One world that + # hangs would otherwise cost timeout x repeats x modes - by far the + # largest thing in a run - and a world that cannot finish one seed has + # nothing useful to say about its stability across ten. + state = {"timed_out": False} + + def skipped(reason): + return {"outcome": "unknown", "detail": reason, "elapsed_seconds": None} + + def run_repeated(players, repeats, single_player=None, options=None, randomized=False): + """The same test several times, reported as a pass rate. + + Stability is not something a single generation can tell you: S, D + and M each get a different Archipelago seed, so the fill differs + every time, and a world that places items successfully four times + in five is genuinely unstable. Running once records whichever + result we happened to get. + + Each repeat uses an explicit seed so a failure among ten passes is + reproducible - without it the interesting case is the one that + cannot be re-examined. + """ + attempts = [] + for _ in range(max(repeats, 1)): + seed = random.randint(0, 2 ** 31 - 1) + # players is a callable so the multi-game test can draw a + # different set of companions every attempt - ten runs against + # one fixed pair only ever tests that pair. + games = players() if callable(players) else players + # A callable, so the randomized modes can roll a different + # point in the option space for every attempt - repeating one + # roll would only re-measure the same combination. + per_game = options() if callable(options) else options + result = run_mode(games, options_per_game=per_game, seed=seed, + single_player=single_player, randomized=randomized) + result["seed"] = seed + if len(games) > 1: + result["companions"] = games[1:] + attempts.append(result) + if result["timed_out"]: + state["timed_out"] = True + break + + passed = [a for a in attempts if a["outcome"] == "passed"] + failed = [a for a in attempts if a["outcome"] != "passed"] + if not failed: + outcome, detail = "passed", f"generated {len(passed)}/{len(attempts)} times" + elif not passed: + # Every attempt agreeing on a non-failure reason keeps that + # reason. A world that needs a base ROM reports needs_input + # once and "failed all 10 attempts" ten times over, and + # flattening those to "failed" blames the world for a file + # this pipeline does not have - which matters most for core + # worlds, where roughly seven of them need a ROM and would + # otherwise all read as Broken. + outcomes = {attempt["outcome"] for attempt in attempts} + outcome = outcomes.pop() if len(outcomes) == 1 else "failed" + detail = f"failed all {len(attempts)} attempts. {failed[0]['detail']}" + if outcome != "failed": + detail = (f"{outcome} on all {len(attempts)} attempts. " + f"{failed[0]['detail']}") + else: + # The whole point of repeating: a world that works sometimes + # is worse for a player than one that never does, because + # nobody knows to avoid it. + outcome = "flaky" + detail = (f"generated {len(passed)}/{len(attempts)} times. " + f"First failure (seed {failed[0]['seed']}): {failed[0]['detail']}") + return { + "outcome": outcome, + "detail": detail, + "elapsed_seconds": round(sum(a["elapsed_seconds"] or 0 for a in attempts), 1), + "attempts": len(attempts), + # What was asked for, as opposed to what ran - a timeout cuts + # a mode short, and the retest cache compares against this so + # such a record is not re-run forever. + "requested_attempts": max(repeats, 1), + "passed_attempts": len(passed), + "failed_seeds": [a["seed"] for a in failed], + # Which companions each failing attempt drew, so a failure + # caused by one particular combination can be reproduced. + "failed_companions": [a.get("companions") for a in failed if a.get("companions")], + } + + tests = {} + + # 1. the world on its own, every option at its default. + tests["single"] = run_repeated([game], args.repeats) + tests["single"]["label"] = "Single game" + + # 2. the same world twice. A world that keeps state on the class + # rather than the instance works alone and corrupts itself here - + # a bug class no other mode can see. + tests["duplicate"] = (skipped("skipped - an earlier test timed out") if state["timed_out"] + else run_mode([game, game], single_player=False)) + tests["duplicate"]["label"] = "Two players, same game" + + # 3. the world alongside other games. Each attempt draws a fresh + # random selection of core worlds rather than reusing one fixed + # pair: ten runs against the same two companions only ever tell you + # about those two, while a different combination each time samples + # what a real multiworld looks like. + configured_pool = [name.strip() for name in args.companion_pool.split(",") if name.strip()] + if configured_pool: + # Verified by the host once per run: every one of these generated + # a seed on its own in this exact environment, so a multi-game + # failure is attributable to the world under test rather than to + # a companion that was never going to work. + pool = [name for name in configured_pool if name != game] + else: + pool = core_worlds(exclude=game) + low = max(args.companion_min, 1) + high = max(args.companion_max, low) + + def draw_companions(): + count = min(random.randint(low, high), len(pool)) + return random.sample(pool, count) + + if state["timed_out"]: + tests["multi"] = skipped("skipped - an earlier test timed out") + elif pool: + tests["multi"] = run_repeated( + lambda: [game] + draw_companions(), args.repeats, single_player=False) + tests["multi"]["companion_pool"] = len(pool) + tests["multi"]["companion_range"] = [low, high] + tests["multi"]["detail"] += f" (against {low}-{high} core games drawn from {len(pool)})" + else: + tests["multi"] = skipped("no core worlds available to pair with") + tests["multi"]["label"] = "Multi game" + + # 4 and 5. The same two shapes as above - the world alone, and the + # world alongside the companions - but with its options randomized + # instead of left at their defaults. The default option set is a + # single point in a world's option space and most option-dependent + # crashes are nowhere near it. + # + # One roll each, and every roll that does not produce a seed is a + # failure, including one the world rejects with OptionError. That + # last point is the whole reason these modes exist: the yaml a + # player writes is accepted as valid - nothing in the template or + # the upload path objects to "random" on two interdependent + # options - so the conflict only surfaces when generation is + # actually attempted, possibly after everyone has submitted, and it + # is intermittent. Re-rolling until the world accepts something + # would hide exactly the failure a real player hits. + # + # Each randomized run is gated on its own default-options + # counterpart: SR only when the world generates alone, MR only when + # it generates alongside the companions. A randomized run whose + # plain equivalent already fails would fail for the same underlying + # reason and cost a generation to learn nothing. + rolls = None + if state["timed_out"]: + aborted = "skipped - an earlier test timed out" + tests["single_random"] = skipped(aborted) + tests["multi_random"] = skipped(aborted) + elif tests["single"]["outcome"] == "passed" or tests["multi"]["outcome"] == "passed": + try: + # One roll per attempt, drawn up front so SR and MR run the + # SAME combinations as each other. Pairing them is what makes + # "SR passed but MR failed" attributable to the extra players + # rather than to two different option sets. + rolls = [random_options(game) for _ in range(max(args.random_repeats, 1))] + except BaseException as error: + rolls = None + unreadable = f"could not read this world's options: {type(error).__name__}: {error}" + tests["single_random"] = skipped(unreadable) + tests["multi_random"] = skipped(unreadable) + + if rolls: + def roller(): + # Walks the pre-drawn rolls in order, so attempt N of SR and + # attempt N of MR use the same option values. + supply = iter(rolls) + def next_roll(): + return {game: next(supply)} + return next_roll + + # "flaky" counts as generating: the world produced a seed at + # least once, so a randomized run can still tell us something. + if tests["single"]["outcome"] in ("passed", "flaky"): + result = run_repeated([game], len(rolls), single_player=True, + options=roller(), randomized=True) + result["randomized_options"] = len(rolls[0]) + tests["single_random"] = result + else: + tests["single_random"] = skipped( + "skipped - the single-game test did not pass") + + if tests["multi"]["outcome"] in ("passed", "flaky"): + result = run_repeated(lambda: [game] + draw_companions(), len(rolls), + single_player=False, options=roller(), randomized=True) + result["randomized_options"] = len(rolls[0]) + tests["multi_random"] = result + else: + tests["multi_random"] = skipped( + "skipped - the multi-game test did not pass") + elif "single_random" not in tests: + neither = "skipped - neither the single-game nor the multi-game test passed" + tests["single_random"] = skipped(neither) + tests["multi_random"] = skipped(neither) + + tests["single_random"]["label"] = "Random options, alone" + tests["multi_random"]["label"] = "Random options, multi game" + + # The top-level fields stay exactly what they were - the single-player + # verdict - because that is what promotion and every existing consumer + # reads. The per-mode results live alongside it. + report = { + "game": game, + "outcome": tests["single"]["outcome"], + "detail": tests["single"]["detail"], + "elapsed_seconds": tests["single"]["elapsed_seconds"], + "multi_outcome": tests["multi"]["outcome"], + "multi_detail": tests["multi"]["detail"], + "multi_elapsed_seconds": tests["multi"]["elapsed_seconds"], + "multi_companions": tests["multi"].get("companions", []), + "tests": tests, + } + + print(json.dumps(report)) + # Exit code still reflects the single-player result alone: it is what + # gates promotion, and the other modes are reported rather than + # blocking. + return {"passed": 0, "needs_input": 2}.get(tests["single"]["outcome"], 1) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/apworld_tester/container/drivers/verify_companions.py b/src/apworld_tester/container/drivers/verify_companions.py new file mode 100644 index 0000000..df58bd5 --- /dev/null +++ b/src/apworld_tester/container/drivers/verify_companions.py @@ -0,0 +1,65 @@ +"""Runs inside the archipelago Docker image. Reports which core worlds +can actually generate a seed in THIS environment, so the multi-game tests +only ever draw companions that work. + +Core worlds are shipped with Archipelago, but that does not make them all +usable here: some need a base ROM this container has no copy of, and at +least one (Final Fantasy) needs a settings yaml generated by an external +website and refuses a generic one. Drawing such a world as a companion +fails the whole seed, and the failure gets recorded against the apworld +under test rather than the companion that caused it - Blender was marked +flaky 7/10 purely because three of its ten draws included worlds like +these. + +Rather than maintain an exclusion list by hand, this generates one solo +seed per core world and keeps the ones that succeed. A world drops out +by itself when its ROM is missing, and comes back by itself once the ROM +is added. + +Shares run_test.py's generation machinery rather than repeating it, so +"can generate" means exactly the same thing in both places. +""" + +import argparse +import json +import sys +import warnings + +sys.path.insert(0, "/app") + +import run_test # noqa: E402 - mounted alongside this script + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", required=True) + parser.add_argument("--timeout", type=int, default=120, + help="Per-world timeout. Deliberately shorter than the apworld " + "timeout: a companion that is slow to generate would multiply " + "across every multi-game attempt that draws it.") + parser.add_argument("--spoiler", type=int, default=2) + args = parser.parse_args() + + warnings.simplefilter("ignore") + run_test.ensure_tutorials_default() + + from pathlib import Path + Path(args.output_dir).mkdir(parents=True, exist_ok=True) + + verified = [] + rejected = {} + for game in run_test.core_worlds(exclude=None): + results, timed_out, elapsed = run_test.run_with_timeout( + [game], args.output_dir, args.timeout, spoiler=args.spoiler) + outcome, detail = run_test.classify(results, timed_out, elapsed) + if outcome == "passed": + verified.append(game) + else: + rejected[game] = f"{outcome}: {detail}"[:300] + + print(json.dumps({"verified": verified, "rejected": rejected})) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/apworld_tester/core/__init__.py b/src/apworld_tester/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apworld_tester/core/config/__init__.py b/src/apworld_tester/core/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apworld_tester/core/config/config.py b/src/apworld_tester/core/config/config.py new file mode 100644 index 0000000..c044e1e --- /dev/null +++ b/src/apworld_tester/core/config/config.py @@ -0,0 +1,131 @@ +"""The project's configuration.""" + +import os + +import yaml + + +class ConfigNotFound(FileNotFoundError): + """No config.yaml anywhere on the search path. + + Carries the places that were tried, because the fix is always to + put a file in one of them and a bare "no such file" names only the + last candidate. + """ + + def __init__(self, candidates): + self.candidates = list(candidates) + listed = "\n".join(f" {path}" for path in self.candidates) + super().__init__( + "no config.yaml found. Looked in:\n" + f"{listed}\n" + "Run 'apworld-tester init' to write a starter config, " + "or pass --config / set APWORLD_TESTER_CONFIG." + ) + + +class ConfigNotFilledIn(ValueError): + """A config that was written by `init` and never edited. + + Its own error rather than the FileNotFoundError the placeholder + path eventually causes: that one names a directory nobody chose and + reads like a bug in the tool. + """ + + def __init__(self, path, setting, value): + self.path = path + self.setting = setting + super().__init__( + f"{setting} is still the example value {value!r}.\n" + f"Edit {path} and set it to a directory on this machine." + ) + + +class Config: + """config.yaml, read once and passed to whatever needs it.""" + + #: Overrides every searched location when set. + ENV_VAR = "APWORLD_TESTER_CONFIG" + + #: How the shipped template writes a path the user must replace. + PLACEHOLDER = "/path/to/your" + + #: Settings that must be real before anything can run. + REQUIRED_PATHS = ("output_directory",) + + #: The directory name used under $XDG_CONFIG_HOME and ~/.config. + APP_DIR = "apworld-tester" + + def __init__(self, values, path=None): + self.values = values + self.path = path + + @classmethod + def load(cls, path=None): + """Read config.yaml from the first place it exists.""" + path = path or cls.default_path() + with open(path, "r", encoding="utf-8") as handle: + config = cls(yaml.safe_load(handle) or {}, path) + config.check_filled_in() + return config + + def check_filled_in(self): + """Refuse a config still carrying the template's paths.""" + for setting in self.REQUIRED_PATHS: + value = self.value("general", setting) + if value and str(value).startswith(self.PLACEHOLDER): + raise ConfigNotFilledIn(self.path, setting, value) + + @staticmethod + def user_path(): + """Where a user's own config.yaml belongs. + + $XDG_CONFIG_HOME when the platform sets it, ~/.config + otherwise - the location `init` writes to and the one the + documentation names. + """ + base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser( + "~/.config") + return os.path.join(base, Config.APP_DIR, "config.yaml") + + @staticmethod + def checkout_path(): + """config.yaml beside a source checkout, above the package. + + Kept in the search order so running from a clone kept working + after installed copies moved to the user config directory. An + installed wheel resolves this to somewhere inside site-packages' + parent, which simply will not exist. + """ + here = os.path.dirname(os.path.abspath(__file__)) + root = os.path.dirname(os.path.dirname(os.path.dirname(here))) + return os.path.join(os.path.dirname(root), "config.yaml") + + @classmethod + def search_paths(cls): + """Every location tried, in order.""" + override = os.environ.get(cls.ENV_VAR) + paths = [override] if override else [] + return paths + [cls.user_path(), cls.checkout_path()] + + @classmethod + def default_path(cls): + """The first config.yaml that exists, or raise saying where.""" + candidates = cls.search_paths() + for path in candidates: + if os.path.isfile(path): + return path + raise ConfigNotFound(candidates) + + def get(self, key, default=None): + """One top-level value.""" + value = self.values.get(key) + return default if value is None else value + + def section(self, name): + return self.values.get(name) or {} + + def value(self, section, key, default=None): + """One value from a section.""" + value = self.section(section).get(key) + return default if value is None else value diff --git a/src/apworld_tester/core/config/paths.py b/src/apworld_tester/core/config/paths.py new file mode 100644 index 0000000..0fc1f85 --- /dev/null +++ b/src/apworld_tester/core/config/paths.py @@ -0,0 +1,125 @@ +"""Every path the pipeline uses.""" + +import os + +from importlib import resources + + +class Paths: + """Where everything lives, derived from one Config. + + The checkout holds code and configuration; the data root holds + everything a run generates, and defaults outside the checkout + because none of that is source. Every path is absolute. + """ + + def __init__(self, config): + self.config = config + + @property + def project(self): + return os.path.dirname(self.config.path) + + @property + def data(self): + """Where everything a run generates lives.""" + configured = self.config.value( + "general", + "output_directory", + "~/.local/share/archipelago-world-tester", + ) + return os.path.abspath(os.path.expanduser(configured)) + + @property + def state(self): + return os.path.join(self.data, "state.json") + + @property + def scratch_state(self): + return os.path.join(self.data, "scratch_state.json") + + @property + def lock(self): + return os.path.join(self.data, "pipeline.lock") + + @property + def downloads(self): + return os.path.join(self.data, "downloads") + + @property + def output(self): + return os.path.join(self.data, "test_output") + + @property + def history(self): + """Every apworld version this pipeline has seen, per game. + + Beside the downloads rather than inside them: that directory is + a cache and gets cleared to force a clean re-download, and this + is the one thing that cannot be rebuilt afterwards. + """ + return os.path.join(self.data, "history") + + @property + def checkout(self): + return os.path.join(self.data, "archipelago_src") + + @property + def core_worlds(self): + return os.path.join(self.checkout, "worlds") + + @property + def common_client(self): + return os.path.join(self.checkout, "CommonClient.py") + + @property + def failed_report(self): + return os.path.join(self.data, "failed_apworlds.txt") + + @property + def sheet_html(self): + return os.path.join(self.data, "playable_worlds.html") + + @property + def roms(self): + """Base ROMs for the worlds that need one.""" + configured = self.config.value("general", "roms_directory") + if not configured: + return os.path.join(self.data, "roms") + return os.path.abspath(os.path.expanduser(configured)) + + @property + def env(self): + return os.path.join(self.project, ".env") + + @property + def container(self): + """The Dockerfile and drivers, resolved inside the package. + + Shipped as package data rather than found beside config.yaml: + an installed copy has no checkout to look next to, and the + config now lives in the user's config directory, which is not + where a Dockerfile belongs. Returned as a real filesystem path + because every use is a docker build context or a bind mount. + """ + return os.fspath(resources.files("apworld_tester") / "container") + + @property + def drivers(self): + return os.path.join(self.container, "drivers") + + @property + def test_dockerfile(self): + return os.path.join(self.container, "Dockerfile.test") + + @property + def driver(self): + return os.path.join(self.drivers, "run_test.py") + + @property + def identify_driver(self): + return os.path.join(self.drivers, "identify_apworld.py") + + @property + def verify_driver(self): + return os.path.join(self.drivers, "verify_companions.py") diff --git a/src/apworld_tester/core/config/run_settings.py b/src/apworld_tester/core/config/run_settings.py new file mode 100644 index 0000000..33aff84 --- /dev/null +++ b/src/apworld_tester/core/config/run_settings.py @@ -0,0 +1,86 @@ +"""How much testing one run asks for. + +Companions are drawn at random from the worlds Archipelago ships. Core +worlds are the safe choice: part of the build itself, so always present +and always matching the running version, unlike an apworld that might +be missing or broken for reasons of its own. A fresh draw per attempt +matters - ten runs against one fixed pair only ever test that pair. +""" + + +class RunSettings: + """The testing section, as the numbers a run is built from.""" + + #: Archipelago's spoiler levels, by what each one does. The numbers + #: are Generate.py's `--spoiler`, not a choice this project makes. + SPOILER_LEVELS = { + "none": 0, + "log": 1, + "playthrough": 2, + "paths": 3, + } + + def __init__(self, config): + self.config = config + + def number(self, key, default): + return int(self.config.value("testing", key, default)) + + @property + def companion_range(self): + """How many core worlds join the world under test. + + Redrawn every attempt, so ten repeats sample ten combinations. + Clamped so a mistyped config cannot ask for an empty or + backwards range. + """ + low = max(self.number("companion_min", 2), 1) + return low, max(self.number("companion_max", 5), low) + + @property + def repeats(self): + """How often the single and multi tests are repeated. + + Stability is a pass rate across runs, not one verdict: each + repeat gets a different seed, so the fill differs every time. + """ + return self.number("repeats", 10) + + @property + def random_repeats(self): + """How often the two randomized modes are repeated. + + Lower on purpose: single and multi re-roll only the seed, so + ten runs measure one thing precisely, while each randomized run + rolls a different point in the option space and three samples + say much more than one did. + """ + return self.number("random_repeats", 3) + + @property + def spoiler(self): + """How much of the spoiler log each generation computes. + + "playthrough" is the level worth paying for: it exercises the + accessibility and reachability logic that plain generation + skips, so a world whose logic is wrong fails here rather than + in a player's seed. "paths" adds entrance paths on top and + "log" writes the file without checking anything. + """ + name = str(self.config.value("testing", "spoiler", "playthrough")) + try: + return self.SPOILER_LEVELS[name.strip().casefold()] + except KeyError: + known = ", ".join(self.SPOILER_LEVELS) + raise ValueError( + f"testing.spoiler is {name!r}; expected one of {known}" + ) from None + + @property + def jobs(self): + """How many apworlds are tested at once. + + Each test is one isolated `docker run` and subprocess.run + releases the GIL while it waits, so threads are all this needs. + """ + return int(self.config.value("general", "threads", 4)) diff --git a/src/apworld_tester/core/config/secrets.py b/src/apworld_tester/core/config/secrets.py new file mode 100644 index 0000000..49f62a4 --- /dev/null +++ b/src/apworld_tester/core/config/secrets.py @@ -0,0 +1,41 @@ +"""Credentials, kept out of config.yaml.""" + +import os + + +class Secrets: + """The .env file at the checkout's root. + + Kept out of config.yaml: that file gets opened, edited and shared, + and a token in it leaks the first time it is. + """ + + #: The environment variable a GitHub token is read from. + GITHUB_TOKEN = "GITHUB_TOKEN" + + def __init__(self, paths): + self.paths = paths + + def values(self): + """NAME -> value for every line in the file.""" + if not os.path.exists(self.paths.env): + return {} + values = {} + with open(self.paths.env, "r", encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + name, value = line.split("=", 1) + values[name.strip()] = value.strip().strip("\"'") + return values + + def get(self, name, default=None): + """One secret, or `default` when it is unset or empty.""" + value = self.values().get(name) + return default if value in (None, "") else value + + @property + def github_token(self): + """The GitHub token, or None.""" + return self.get(self.GITHUB_TOKEN) diff --git a/src/apworld_tester/core/display/__init__.py b/src/apworld_tester/core/display/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apworld_tester/core/display/progress.py b/src/apworld_tester/core/display/progress.py new file mode 100644 index 0000000..cad572b --- /dev/null +++ b/src/apworld_tester/core/display/progress.py @@ -0,0 +1,108 @@ +"""Progress reporting for the long stages.""" + +import shutil +import sys +import time + + +class Progress: + """One stage's progress, tallied by outcome as it goes. + + Both the download stage and the generation tests walk ~650 games, so + per-line output scrolls past uselessly when a run is watched. Under + cron the opposite is true - stdout is a log file, where a bar + redrawn with carriage returns is noise - so each caller supplies the + line it would have printed, and that is what a non-terminal gets. + """ + + def __init__(self, total, config=None, labels=None, stream=None): + self.total = total + self.config = config + self.labels = dict(labels or self.configured_labels()) + self.stream = stream or sys.stdout + self.counts = {} + self.started = None + + def configured_labels(self): + """Short labels for each outcome.""" + if self.config is None: + return {} + return self.config.value("display", "outcome_labels", {}) + + @property + def width(self): + """How many characters wide the bar is drawn.""" + if self.config is None: + return 28 + return int(self.config.value("display", "progress_bar_width", 28)) + + @staticmethod + def duration(seconds): + """Seconds as m:ss, or h:mm:ss past an hour.""" + seconds = int(seconds) + hours, rest = divmod(seconds, 3600) + minutes, seconds = divmod(rest, 60) + if hours: + return f"{hours}:{minutes:02d}:{seconds:02d}" + return f"{minutes:02d}:{seconds:02d}" + + @property + def tally(self): + """The running counts, labelled keys first.""" + ordered = [key for key in self.labels if self.counts.get(key)] + ordered += [key for key in self.counts if key not in self.labels] + return " ".join( + f"{self.labels.get(key, key)} {self.counts[key]}" + for key in ordered + ) + + def bar(self, fraction): + """The bar itself, filled to `fraction`.""" + filled = int(self.width * fraction) + return "#" * filled + "-" * (self.width - filled) + + def eta(self, index, elapsed): + """Time remaining at the current rate, blank once finished.""" + if not index or index >= self.total: + return "" + return f" eta {self.duration(elapsed / index * (self.total - index))}" + + def line(self, index, text): + """The whole status line for this update. + + Position and time only. The running tally and the item's name + belong to the summary printed when the stage ends - on the bar + they change every redraw and are unreadable while doing it. + """ + fraction = index / self.total if self.total else 1 + elapsed = time.monotonic() - self.started + return (f"[{self.bar(fraction)}] {index}/{self.total} " + f"{fraction * 100:3.0f}% " + f"{self.duration(elapsed)}{self.eta(index, elapsed)}") + + def draw(self, index, text): + """Redraw the bar in place, ending the line when finished. + + Padded to overwrite a previously longer line, then trimmed to + the terminal: a wrapped line cannot be overwritten by a carriage + return, and the bar starts spilling down the screen. + """ + columns = shutil.get_terminal_size((100, 24)).columns + padded = self.line(index, text)[:columns - 1].ljust(columns - 1) + self.stream.write("\r" + padded) + self.stream.flush() + if index >= self.total: + self.stream.write("\n") + self.stream.flush() + + def update(self, index, key=None, text="", fallback_line=None): + """Record one finished item and show where the stage is.""" + if self.started is None: + self.started = time.monotonic() + if key is not None: + self.counts[key] = self.counts.get(key, 0) + 1 + if not self.stream.isatty(): + if fallback_line is not None: + print(fallback_line, file=self.stream) + return + self.draw(index, text) diff --git a/src/apworld_tester/core/model/__init__.py b/src/apworld_tester/core/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apworld_tester/core/model/apworld.py b/src/apworld_tester/core/model/apworld.py new file mode 100644 index 0000000..40bb2fa --- /dev/null +++ b/src/apworld_tester/core/model/apworld.py @@ -0,0 +1,120 @@ +"""The filename an apworld needs before Archipelago will import it.""" + +import json +import os +import re +import zipfile + + +class ApworldFile: + """One downloaded apworld, and what its name has to be. + + Archipelago derives the module name from the FILENAME, so a version + in the name reads as package separators and a backslash is just a + character. The right name is the zip's own package folder. + """ + + #: The extension an apworld is recognised by. + SUFFIX = ".apworld" + + def __init__(self, path): + self.path = path + + @property + def entries(self): + """Every entry name in the zip, empty if it will not open.""" + try: + with zipfile.ZipFile(self.path) as archive: + return archive.namelist() + except (OSError, zipfile.BadZipFile): + return [] + + @property + def uses_backslash_separators(self): + """Whether the zip stores its paths with backslashes. + + Invisible on Windows and fatal on Linux: zipimport swaps "/" for + the platform separator when reading, so on Windows both forms + match and on Linux the backslash entry never matches the + "pkg/__init__.py" it looks for. The author sees a working + apworld and every Linux user sees ModuleNotFoundError. + """ + names = self.entries + if not names: + return False + return (not any("/" in name for name in names) + and any("\\" in name for name in names)) + + @property + def manifest(self): + """The apworld's own archipelago.json, or None. + + Its self-declared "game" and "world_version", sitting in the + file's content regardless of which release was resolved for it - + or whether one ever was. Older apworlds predate the convention + and ship none, so this is best-effort display information rather + than something worth failing over. + """ + try: + with zipfile.ZipFile(self.path) as archive: + name = next((entry for entry in archive.namelist() + if entry.endswith("/archipelago.json")), None) + if name is None: + return None + with archive.open(name) as handle: + return json.load(handle) + except (OSError, zipfile.BadZipFile, json.JSONDecodeError, KeyError): + return None + + @property + def package_folder(self): + """The single top-level package inside the zip, or None.""" + names = [name.replace("\\", "/") for name in self.entries] + packages = { + name.split("/", 1)[0] + for name in names + if name.count("/") == 1 and name.endswith("/__init__.py") + } + return packages.pop() if len(packages) == 1 else None + + @property + def importable_name(self): + """What this file must be called for Archipelago to import it.""" + base = os.path.basename(self.path.replace("\\", "/")) + if base.lower().endswith(ApworldFile.SUFFIX): + stem = base[:-len(ApworldFile.SUFFIX)] + else: + stem = base + stem = self.package_folder or self.sanitize(stem) + return stem + ApworldFile.SUFFIX + + def sanitize(self, stem): + """A filename stem reduced to something importable.""" + base = os.path.basename(stem.replace("\\", "/")) + cleaned = re.sub(r"[^0-9A-Za-z_]", "_", base).strip("_") + if cleaned and cleaned[0].isdigit(): + cleaned = "_" + cleaned + return cleaned or "apworld" + + def prepare(self, working_directory): + """The path to hand Archipelago, and whether it was repaired.""" + if not self.uses_backslash_separators: + return self.path, False + os.makedirs(working_directory, exist_ok=True) + destination = os.path.join(working_directory, self.importable_name) + return self.write_normalized(destination), True + + def write_normalized(self, destination): + """Copy the apworld with its separators repaired.""" + with zipfile.ZipFile(self.path) as source: + with zipfile.ZipFile(destination, "w", + zipfile.ZIP_DEFLATED) as target: + for item in source.infolist(): + repaired = zipfile.ZipInfo( + filename=item.filename.replace("\\", "/"), + date_time=item.date_time, + ) + repaired.compress_type = item.compress_type + repaired.external_attr = item.external_attr + target.writestr(repaired, source.read(item.filename)) + return destination diff --git a/src/apworld_tester/core/model/blacklist.py b/src/apworld_tester/core/model/blacklist.py new file mode 100644 index 0000000..29da30e --- /dev/null +++ b/src/apworld_tester/core/model/blacklist.py @@ -0,0 +1,30 @@ +"""Worlds this pipeline declines to test.""" + +from apworld_tester.core.model.name import Name + + +class Blacklist: + """Worlds not tested. + + A world that times out is the most expensive thing in a run and the + least informative. Entries are permanent until removed by hand - + "do not retry" is the whole reason one is listed. + """ + + #: Recorded verdict for a world that was never tested. + OUTCOME = "unknown" + + #: What the status page shows in place of a test result. + DETAIL = "not tested: blacklisted" + + def __init__(self, config): + self.config = config + + @property + def entries(self): + """Every listed world, by folded name.""" + listed = self.config.get("blacklist", []) or [] + return {Name(name).slug for name in listed if Name(name).slug} + + def listed(self, game_name): + return Name(game_name).slug in self.entries diff --git a/src/apworld_tester/core/model/fingerprint.py b/src/apworld_tester/core/model/fingerprint.py new file mode 100644 index 0000000..14c3c90 --- /dev/null +++ b/src/apworld_tester/core/model/fingerprint.py @@ -0,0 +1,30 @@ +"""Content hashes, used to decide what has changed.""" + +import hashlib +import os + + +class Fingerprint: + """The content of a file or a directory, as one hash.""" + + @staticmethod + def of_file(path): + """One file's sha256, read in chunks.""" + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + @classmethod + def of_directory(cls, directory): + """One hash covering every file directly inside a directory.""" + digest = hashlib.sha256() + if not os.path.isdir(directory): + return digest.hexdigest() + for name in sorted(os.listdir(directory)): + path = os.path.join(directory, name) + if os.path.isfile(path): + digest.update(name.encode("utf-8")) + digest.update(cls.of_file(path).encode("utf-8")) + return digest.hexdigest() diff --git a/src/apworld_tester/core/model/game.py b/src/apworld_tester/core/model/game.py new file mode 100644 index 0000000..e1d0661 --- /dev/null +++ b/src/apworld_tester/core/model/game.py @@ -0,0 +1,47 @@ +"""One game from the worlds sheet.""" + +import re + + +class Game: + """A sheet row: a name, its links, and what the sheet says about it.""" + + def __init__(self, name, links=None, release=None, stability=None, + pr_status=None): + self.name = name + self.links = list(links or []) + self.release = release + self.stability = stability + self.pr_status = pr_status + for link in self.links: + self.remember_release(link) + + @staticmethod + def is_release_link(link): + """Whether a link points at a GitHub releases page.""" + return bool(re.match( + r"^https?://(?:www\.)?github\.com/[^/]+/[^/]+/releases" + r"(?:[/?#]|$)", + link or "", + re.IGNORECASE, + )) + + @property + def is_core(self): + return (self.pr_status or "").strip().lower() == "core" + + def remember_release(self, link): + """Keep this link as the release URL if none was found yet.""" + if self.release is None and self.is_release_link(link): + self.release = link + return self.release + + def add_link(self, link): + """Add a link, ignoring one already recorded.""" + if link not in self.links: + self.links.append(link) + self.remember_release(link) + return self.links + + def __str__(self): + return f"{self.name} ({len(self.links)} links)" diff --git a/src/apworld_tester/core/model/instant.py b/src/apworld_tester/core/model/instant.py new file mode 100644 index 0000000..14b97be --- /dev/null +++ b/src/apworld_tester/core/model/instant.py @@ -0,0 +1,35 @@ +"""One timestamp, as the moment it names.""" + +import datetime + + +class Instant: + """An ISO 8601 timestamp parsed into a comparable moment. + + Hosts do not agree on how to write the offset: GitHub sends a + trailing "Z", this pipeline's own clock writes "+00:00", and GitLab + and Gitea send a local one ("...19:55:36-04:00" is really 23:55Z). + Compared as text those sort by the numbers on the clock face rather + than by when they happened, so anything comparing two timestamps + from different sources has to parse them first. + """ + + @staticmethod + def of(text): + """One timestamp, or None when it is not a usable one. + + A naive timestamp is read as UTC: everything written here is, + and the alternative is a comparison that raises rather than one + that is merely approximate. "Z" is spelled out because + fromisoformat only accepts it from 3.11. + """ + if not text: + return None + try: + parsed = datetime.datetime.fromisoformat( + str(text).replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=datetime.timezone.utc) + return parsed diff --git a/src/apworld_tester/core/model/modes.py b/src/apworld_tester/core/model/modes.py new file mode 100644 index 0000000..8433e71 --- /dev/null +++ b/src/apworld_tester/core/model/modes.py @@ -0,0 +1,22 @@ +"""The generation modes a complete result carries.""" + + +class Modes: + """What the driver runs against every world. + + Not configuration: each name is a branch in the container driver + and in Verdict, so a mode cannot be added or removed by editing a + list. Changing this would only desynchronise the cache check and + the results table from what actually ran. + """ + + SINGLE = "single" + DUPLICATE = "duplicate" + MULTI = "multi" + SINGLE_RANDOM = "single_random" + MULTI_RANDOM = "multi_random" + + ALL = (SINGLE, DUPLICATE, MULTI, SINGLE_RANDOM, MULTI_RANDOM) + + #: The modes that cannot demote a world on their own. + SECONDARY = (DUPLICATE, SINGLE_RANDOM, MULTI_RANDOM) diff --git a/src/apworld_tester/core/model/name.py b/src/apworld_tester/core/model/name.py new file mode 100644 index 0000000..6453438 --- /dev/null +++ b/src/apworld_tester/core/model/name.py @@ -0,0 +1,42 @@ +"""Folding a game name down to a comparison key.""" + +import re +import unicodedata + + +class Name: + """One game name, and the two ways it gets folded. + + One implementation, because five copies once disagreed and a world + silently lost its blacklist entry. + """ + + def __init__(self, text): + self.text = text or "" + + @property + def slug(self): + """The key for matching this name against another.""" + folded = unicodedata.normalize("NFKD", self.text.lower()) + return re.compile(r"[^a-z0-9]").sub("", folded) + + @property + def report_slug(self): + return re.compile(r"[^a-z0-9]+").sub("-", self.text.lower()).strip("-") + + @property + def directory(self): + return re.sub(r'[<>:"/\\|?*]', "_", self.text).strip().rstrip(".") + + def matches(self, other): + """Whether an internally registered name means this game. + + One-directional: the internal name is usually the shorter, + canonical one and the sheet's is often longer, so checking + containment the other way would let a short internal name match + an unrelated row that happens to start the same way. + """ + if not self.text or not other: + return False + internal = Name(other).slug + return internal == self.slug or internal in self.slug diff --git a/src/apworld_tester/core/model/verdict.py b/src/apworld_tester/core/model/verdict.py new file mode 100644 index 0000000..b659cf3 --- /dev/null +++ b/src/apworld_tester/core/model/verdict.py @@ -0,0 +1,71 @@ +"""Where a world's test record lands on the stability ladder.""" + +from apworld_tester.core.model.modes import Modes + + +class Verdict: + """One world's tested stability. + + The ladder: broken, unknown, flaky, solo_only, unstable, stable. + Single and multi decide the first four, because those are what a + player does; the other modes can only stop a world being stable. + """ + + #: Outcomes that count as a failure in a secondary mode. + FAILURES = ("failed", "invalid_options", "flaky") + + def __init__(self, record, config): + self.record = record or {} + self.config = config + + @property + def modes(self): + return self.record.get("tests") or {} + + @property + def never_imported(self): + return self.record.get("outcome") == "unverified" + + def outcome_of(self, mode): + return (self.modes.get(mode) or {}).get("outcome") + + @property + def primary(self): + """What the single and multi runs decide, or None if they pass.""" + single = self.outcome_of(Modes.SINGLE) + multi = self.outcome_of(Modes.MULTI) + if single == "needs_input" or multi == "needs_input": + return "unknown" + if single == "failed": + return "broken" + if single == "flaky" or multi == "flaky": + return "flaky" + if multi == "failed": + return "solo_only" + if single != "passed": + return "unknown" + return None + + @property + def secondary_failed(self): + """Whether a mode that cannot demote alone still failed.""" + return any(self.outcome_of(mode) in self.FAILURES + for mode in Modes.SECONDARY) + + @property + def name(self): + """Where this record lands on the ladder.""" + if self.never_imported: + return "broken" + if not self.modes or not self.outcome_of(Modes.SINGLE): + return "unknown" + decided = self.primary + if decided is not None: + return decided + return "unstable" if self.secondary_failed else "stable" + + @property + def label(self): + labels = self.config.value("stability", "labels", {}) + return labels.get(self.name, "Unknown") + diff --git a/src/apworld_tester/core/state/__init__.py b/src/apworld_tester/core/state/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apworld_tester/core/state/keys.py b/src/apworld_tester/core/state/keys.py new file mode 100644 index 0000000..312ce02 --- /dev/null +++ b/src/apworld_tester/core/state/keys.py @@ -0,0 +1,23 @@ +"""The keys this pipeline writes into state.json.""" + + +class StateKeys: + """Names of the entries in the state file. + + Not configuration: state.json is written and read only by this + pipeline, so renaming a key here would not migrate the file, it + would orphan every record already in it. + """ + + APWORLD_TESTS = "apworld_tests" + UPGRADE_SNAPSHOT = "apworld_tests_at_last_upgrade" + ARCHIPELAGO_TAG = "archipelago_tag" + ARCHIPELAGO_TAG_PUBLISHED_AT = "archipelago_tag_published_at" + PREVIOUS_ARCHIPELAGO_TAG = "previous_archipelago_tag" + COMPANION_POOL = "verified_companions" + GAME_RELEASES = "game_releases" + DISCONTINUED = "discontinued" + LAST_CHECKED_AT = "last_checked_at" + FAILED_RELEASE = "failed_release" + FAILED_REASON = "failed_reason" + STABILITY_HISTORY = "stability_history" diff --git a/src/apworld_tester/core/state/lock.py b/src/apworld_tester/core/state/lock.py new file mode 100644 index 0000000..c073a55 --- /dev/null +++ b/src/apworld_tester/core/state/lock.py @@ -0,0 +1,37 @@ +"""The exclusive lock a whole run holds.""" + +import fcntl + + +class LockHeldError(RuntimeError): + """Another run already holds the lock.""" + + +class PipelineLock: + """Stops two runs, or a run and a promotion, from overlapping. + + Non-blocking: a second run fails fast rather than queuing. Queuing + would be worse - a full pass takes hours, so a queued one would + start against an Archipelago version that had moved on. + """ + + def __init__(self, path): + self.path = path + self.handle = None + + def __enter__(self): + self.handle = open(self.path, "a+") + try: + fcntl.flock(self.handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as error: + self.handle.close() + raise LockHeldError( + f"Another run already holds {self.path} - refusing to " + "start." + ) from error + return self + + def __exit__(self, *unused): + fcntl.flock(self.handle, fcntl.LOCK_UN) + self.handle.close() + return False diff --git a/src/apworld_tester/core/state/scratch.py b/src/apworld_tester/core/state/scratch.py new file mode 100644 index 0000000..b70fe08 --- /dev/null +++ b/src/apworld_tester/core/state/scratch.py @@ -0,0 +1,32 @@ +"""Choosing between the live record and a scratch copy of it.""" + +import os +import shutil + + +class ScratchState: + """Where a run's results go, live or not. + + A run is an experiment unless it says otherwise: promotion and the + status page read the live file, so a hand-run test that wrote there + would put a world nobody can download into the deployable record. + + Seeding the scratch copy from the live one is what makes that cheap + rather than punishing - without it a scratch run has no recorded + Archipelago version to test against and no verified companion pool, + so it would stop for the first and re-check every core world for + the second. + """ + + def __init__(self, paths, live=False): + self.paths = paths + self.live = live + + @property + def path(self): + if self.live: + return self.paths.state + scratch = self.paths.scratch_state + if not os.path.exists(scratch) and os.path.exists(self.paths.state): + shutil.copy(self.paths.state, scratch) + return scratch diff --git a/src/apworld_tester/core/state/store.py b/src/apworld_tester/core/state/store.py new file mode 100644 index 0000000..8e4b39f --- /dev/null +++ b/src/apworld_tester/core/state/store.py @@ -0,0 +1,55 @@ +"""Reading and writing the pipeline's JSON state.""" + +import json +import os +import tempfile + + +class StateStore: + """What every world last did, and the companion pool behind it.""" + + def __init__(self, path): + self.path = path + + def load(self): + return self.read(self.path, {}) + + def save(self, data): + self.write(self.path, data) + + @staticmethod + def read(path, default=None): + """A JSON file's contents, or `default` when it is absent.""" + if not os.path.exists(path): + return {} if default is None else default + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + + @staticmethod + def write(path, data): + """Write JSON so a reader never sees a partial file. + + A kill mid-write used to leave state.json truncated mid-object - + valid up to the cut point and invalid after it - silently + costing every recorded result on the next load. The batch saves + after every single test, so that window was hit often. + + os.replace is atomic: the file is always either the old complete + version or the new one. + """ + directory = os.path.dirname(os.path.abspath(path)) or "." + handle_id, temporary = tempfile.mkstemp( + dir=directory, prefix=".state_", suffix=".tmp") + try: + with os.fdopen(handle_id, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + except BaseException: + try: + os.remove(temporary) + except OSError: + pass + raise diff --git a/src/apworld_tester/pipeline/__init__.py b/src/apworld_tester/pipeline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apworld_tester/pipeline/batch/__init__.py b/src/apworld_tester/pipeline/batch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apworld_tester/pipeline/batch/apworld_finder.py b/src/apworld_tester/pipeline/batch/apworld_finder.py new file mode 100644 index 0000000..50f9a3a --- /dev/null +++ b/src/apworld_tester/pipeline/batch/apworld_finder.py @@ -0,0 +1,67 @@ +"""Finding the apworlds already downloaded.""" + +import os + + +class ApworldFinder: + """The download directory, as the worlds a run can test.""" + + def __init__(self, root): + self.root = root + + @staticmethod + def matches(game_name, only, exact): + if not only: + return True + if exact: + return any(t.casefold() == game_name.casefold() for t in only) + return any(t.lower() in game_name.lower() for t in only) + + def in_folder(self, game_dir): + """Every apworld directly inside one game's folder. + + A directory where a file should be is skipped rather than + crashing the batch: that is almost always docker auto-creating + an empty directory at a bind-mount source that did not exist + yet, and a fresh download replaces it next time. + """ + found = [] + for entry in sorted(os.listdir(game_dir)): + if not entry.lower().endswith(".apworld"): + continue + path = os.path.join(game_dir, entry) + if not os.path.isfile(path): + print(f"Skipping {path} - not a regular file (directory?)") + continue + found.append(path) + return found + + def find(self, only=None, exact=False): + """Every downloaded apworld, optionally narrowed to `only`. + + `only` matches on substring, which is what --only wants. That + is ambiguous when one game's name contains another's - "Hollow + Knight" also selects "Hollow Knight_ Silksong" - so a caller + that already resolved a term to a real folder passes exact. + """ + found = [] + if not os.path.isdir(self.root): + return found + for game_name in sorted(os.listdir(self.root)): + if not self.matches(game_name, only, exact): + continue + game_dir = os.path.join(self.root, game_name) + if not os.path.isdir(game_dir): + continue + found += [(game_name, path) for path in self.in_folder(game_dir)] + return found + + @property + def available(self): + return sorted({name for name, _ in self.find()}) + + def resolve(self, term): + """The folders a name selects, an exact match winning.""" + matches = sorted({name for name, _ in self.find(only=[term])}) + exact = [n for n in matches if n.casefold() == term.casefold()] + return exact or matches diff --git a/src/apworld_tester/pipeline/batch/batch.py b/src/apworld_tester/pipeline/batch/batch.py new file mode 100644 index 0000000..89c24b3 --- /dev/null +++ b/src/apworld_tester/pipeline/batch/batch.py @@ -0,0 +1,119 @@ +"""Testing every downloaded apworld and recording what each did.""" + +from apworld_tester.core.model.fingerprint import Fingerprint +from apworld_tester.core.state.keys import StateKeys +from apworld_tester.core.state.store import StateStore +from apworld_tester.pipeline.batch.companion_pool import CompanionPool +from apworld_tester.pipeline.batch.failed_report import FailedReport +from apworld_tester.pipeline.batch.options import Options +from apworld_tester.pipeline.batch.planner import Planner +from apworld_tester.pipeline.batch.runner import Runner +from apworld_tester.pipeline.batch.work_list import WorkList + + +class Batch: + """One run: plan it, serve what is cached, test the rest. + + An apworld whose file content and Archipelago version both match + its last recorded test is served from cache. The caller forces a + full re-test when the version changes, because last week's passes + no longer apply to anything. + """ + + def __init__(self, config, paths, settings, image, tag, force=False, + on_progress=None, **asked): + self.config = config + self.paths = paths + self.settings = settings + self.image = image + self.tag = tag + self.force = force or bool(asked.get("only")) + self.on_progress = on_progress + self.options = Options(paths, settings, **asked).resolve() + self.store = StateStore(self.options["state_path"]) + + @property + def writes_live_report(self): + """Whether this run owns the "what is broken" report. + + The report is about the deployable, current-version state: a + previous-version comparison writing here would conflate "not + promotable now" with "does not work on a release nobody runs", + and a run pointed at a scratch state file is not the live + record either. + """ + return (self.options["state_key"] + == StateKeys.APWORLD_TESTS + and self.options["state_path"] == self.paths.state) + + def pool(self): + return CompanionPool( + config=self.config, + paths=self.paths, + store=self.store, + image=self.image, + tag=self.tag, + roms_directory=self.options["roms_directory"], + ).resolve() + + def plan(self, tests, fingerprint, state): + """Everything to consider, what is known, what to run. + + Blacklisted rows and the confirmation date on a cached one are + the entries written outside the runner, which is what normally + persists state - without the save here they would exist only + in memory, and a run that tested nothing would write nothing. + """ + work = WorkList( + root=self.options["root_directory"], + only=self.options["only"], + exact=self.options["exact_only"], + core_games=self.options["core_games"], + ).items() + planner = Planner( + config=self.config, + settings=self.settings, + tests=tests, + tag=self.tag, + roms_fingerprint=fingerprint, + force=self.force, + repeats=self.options["repeats"], + random_repeats=self.options["random_repeats"], + companion_range=self.options["companion_range"], + # A release published after a world's last result + # invalidates it, whatever the file on disk hashes to. + releases=state.get(StateKeys.GAME_RELEASES, {}), + ) + results, to_test = planner.plan(work) + if planner.wrote_state: + self.store.save(state) + return work, results, to_test + + def report_cached(self, results, total): + """Free to produce, so reported without waiting on the pool.""" + for index, report in enumerate(results, start=1): + if self.on_progress: + self.on_progress(index, total, report) + + def run(self): + self.options["pool"] = self.pool() + state = self.store.load() + tests = state.setdefault(self.options["state_key"], {}) + fingerprint = Fingerprint.of_directory(self.options["roms_directory"]) + work, results, to_test = self.plan(tests, fingerprint, state) + self.report_cached(results, len(work)) + if to_test: + results += Runner( + config=self.config, + paths=self.paths, + store=self.store, + options=self.options, + state=state, + tests=tests, + tag=self.tag, + image=self.image, + roms_fingerprint=fingerprint, + ).run(to_test, len(work), len(results), self.on_progress) + if self.writes_live_report: + FailedReport(state, self.paths.failed_report).write() + return results diff --git a/src/apworld_tester/pipeline/batch/cache_check.py b/src/apworld_tester/pipeline/batch/cache_check.py new file mode 100644 index 0000000..49977c5 --- /dev/null +++ b/src/apworld_tester/pipeline/batch/cache_check.py @@ -0,0 +1,120 @@ +"""Whether a previous result still answers this run's question.""" + +import re + +from apworld_tester.core.model.instant import Instant +from apworld_tester.core.model.modes import Modes + + +class CacheCheck: + """One world's recorded result, against what is being asked now.""" + + def __init__(self, settings, previous): + self.settings = settings + self.previous = previous + + #: Failure text pointing at a base ROM, not the world's logic. A + #: wrong or missing ROM surfaces under many exception types, so the + #: type cannot recognise one and the message is what is left. + #: Word-bounded so "from" does not read as "rom". + ROM_FAILURE = re.compile( + r"\b(rom|md5|sha1|checksum)\b" + r"|\.(sfc|smc|z64|n64|nes|gba|gbc|gb|gen|md|iso|bin)\b", + re.IGNORECASE, + ) + + def mentions_rom(self, detail): + return bool(detail and self.ROM_FAILURE.search(str(detail))) + + def needs_rom_retry(self, roms_fingerprint): + """Whether a changed ROM folder invalidates this result. + + A missing ROM raises FileNotFoundError and lands in + "needs_input", but a WRONG one raises whatever the world throws + after reading it and lands in "failed" - so retrying only + needs_input meant replacing a bad dump could never clear the + result. A "failed" result additionally has to look like a ROM + problem: most failures carrying a stale fingerprint were fill + failures and deprecated APIs that no ROM will fix. + """ + if self.previous is None: + return False + outcome = self.previous.get("outcome") + rom_related = (outcome == "needs_input" + or (outcome == "failed" + and self.mentions_rom(self.previous.get("detail")))) + return (rom_related and self.previous.get("roms_fingerprint") + != roms_fingerprint) + + def released_since(self, released_at): + """Whether the row's apworld was published after it was tested. + + The hash catches a file whose content changed, but not a host + that offered a new one and had it turn out identical - and + "identical" is only ever this pipeline's word for it, resting + on the copy it happened to hold. A publication later than the + answer means the answer was reached before whatever is being + offered now, and testing again is the only thing that settles + it. It settles it for good, too: the new result is dated after + the release, so this stops firing until the release moves + again. + """ + if self.previous is None: + return False + released = Instant.of(released_at) + tested = Instant.of(self.previous.get("tested_at")) + if released is None or tested is None: + return False + return tested < released + + @staticmethod + def attempts(recorded_modes): + """How many attempts a cached record was reached with. + + The randomized modes used to run exactly once and carried no + count at all, so a missing value there means "one". + """ + if not recorded_modes: + return None, None + return ( + (recorded_modes.get("single") or {}).get("requested_attempts"), + (recorded_modes.get("single_random") or {}) + .get("requested_attempts"), + ) + + def answers(self, repeats, random_repeats, companion_range): + """Whether the record answers what this run is asking. + + It does not when the record predates per-mode results, when the + set of modes has changed, when it was reached with fewer + repeats - "it generated once" is not "it generated ten times + out of ten" - or when the companion range differs. A world that + never imported is exempt: "unverified" is a complete answer + about this exact file, and the file has to change for the + answer to. + """ + if self.previous is None: + return True + if self.previous.get("outcome") == "unverified": + return True + recorded = self.previous.get("tests") + if recorded is None or set(recorded) != set(Modes.ALL): + return False + single, randomized = self.attempts(recorded) + if single != repeats: + return False + if randomized is not None and randomized != random_repeats: + return False + recorded_range = (recorded.get("multi") or {}).get( + "companion_range") or [] + return list(recorded_range) == list(companion_range) + + def serves(self, current_hash, tag, roms_fingerprint, repeats, + random_repeats, companion_range, released_at=None): + """Whether this world can be served from its previous result.""" + return (self.previous is not None + and self.previous.get("hash") == current_hash + and self.previous.get("tag") == tag + and not self.needs_rom_retry(roms_fingerprint) + and not self.released_since(released_at) + and self.answers(repeats, random_repeats, companion_range)) diff --git a/src/apworld_tester/pipeline/batch/companion_pool.py b/src/apworld_tester/pipeline/batch/companion_pool.py new file mode 100644 index 0000000..ad6baa8 --- /dev/null +++ b/src/apworld_tester/pipeline/batch/companion_pool.py @@ -0,0 +1,65 @@ +"""The core worlds a run may use as companions.""" + +from datetime import datetime, timezone + +from apworld_tester.core.model.fingerprint import Fingerprint +from apworld_tester.core.state.keys import StateKeys +from apworld_tester.pipeline.generation.companion_check import ( + CompanionCheck, +) + + +class CompanionPool: + """Verified once per run and cached against version and ROMs. + + The answer only changes when Archipelago or the ROMs folder does, + and what it rejects would otherwise fail seeds belonging to the + world under test. + """ + + def __init__(self, config, paths, store, image, tag, roms_directory): + self.config = config + self.paths = paths + self.store = store + self.image = image + self.tag = tag + self.fingerprint = Fingerprint.of_directory(roms_directory) + + @property + def key(self): + return StateKeys.COMPANION_POOL + + def cached(self, state): + cached = state.get(self.key) or {} + if (cached.get("tag") == self.tag + and cached.get("roms_fingerprint") == self.fingerprint): + return cached.get("games") or [] + return None + + def store_result(self, state, result): + state[self.key] = { + "tag": self.tag, + "roms_fingerprint": self.fingerprint, + "games": result["verified"], + "rejected": result["rejected"], + "checked_at": datetime.now(timezone.utc).isoformat(), + } + self.store.save(state) + print(f"{len(result['verified'])} core worlds usable as companions, " + f"{len(result['rejected'])} rejected") + for game, reason in sorted(result["rejected"].items()): + print(f" {game}: {reason[:90]}") + return result["verified"] + + def resolve(self): + state = self.store.load() + cached = self.cached(state) + if cached is not None: + return cached + result = CompanionCheck(self.config, self.paths, self.image).run( + self.paths.output) + if not result.get("ran"): + print(f"companion verification failed ({result.get('detail')}) - " + "multi-game tests will draw from every core world") + return [] + return self.store_result(state, result) diff --git a/src/apworld_tester/pipeline/batch/discontinued.py b/src/apworld_tester/pipeline/batch/discontinued.py new file mode 100644 index 0000000..8d84fb0 --- /dev/null +++ b/src/apworld_tester/pipeline/batch/discontinued.py @@ -0,0 +1,69 @@ +"""Flagging test records whose game left the sheet.""" + +from apworld_tester.core.model.name import Name +from apworld_tester.core.state.keys import StateKeys + + +class Discontinued: + """A world taken off the sheet stops being one this pipeline + tracks: it should leave the status page and stop being deployed. + The record is kept and labelled rather than deleted, so its history + survives and a row that comes back - or that vanished because of a + bad sheet parse - is one flag away from being live again. + """ + + def __init__(self, store): + self.store = store + + @staticmethod + def sheet_name(key, record): + """The sheet row a record's key came from. + + A download- or core-stage record is keyed on the bare sheet + name, since nothing was ever fetched for it. Everything else is + keyed "/.apworld". The record's own "game" is + deliberately not used: that is the name Archipelago registered + the world under, which can differ from the sheet's wording. + """ + if (record or {}).get("stage") in ("download", "core"): + return key + return key.split("/", 1)[0] + + def apply(self, tests, live): + """Set or clear the flag, reporting only what moved. + + Comparison is on the folded directory name of both sides, which + is what turned the sheet name into the download folder in the + first place, so punctuation and case cannot make a still-listed + game look discontinued. An unchanged run leaves the state file + byte-identical. + """ + flag = StateKeys.DISCONTINUED + changed = {} + for key, record in tests.items(): + name = self.sheet_name(key, record) + gone = Name(name).directory not in live + if gone != bool(record.get(flag)): + changed[key] = gone + if gone: + record[flag] = True + else: + record.pop(flag, None) + return changed + + def mark(self, sheet_names): + """Flag every record whose game is no longer listed. + + Does nothing when handed no sheet names: an empty list means + the sheet was not read, not that every world on earth was + retired, and acting on it would empty the page. + """ + if not sheet_names: + return {} + state = self.store.load() + tests = state.get(StateKeys.APWORLD_TESTS, {}) + changed = self.apply( + tests, {Name(name).directory for name in sheet_names}) + if changed: + self.store.save(state) + return changed diff --git a/src/apworld_tester/pipeline/batch/download_failures.py b/src/apworld_tester/pipeline/batch/download_failures.py new file mode 100644 index 0000000..1798c3e --- /dev/null +++ b/src/apworld_tester/pipeline/batch/download_failures.py @@ -0,0 +1,88 @@ +"""Recording games whose apworld could never be fetched.""" + +from datetime import datetime, timezone + +from apworld_tester.core.model.name import Name +from apworld_tester.core.state.keys import StateKeys +from apworld_tester.pipeline.batch.record import TestRecord + + +class DownloadFailures: + """A game with no file has nothing to key a result on the way a + tested world does, so it is keyed on its own sheet name - and still + shows up as an unknown row rather than vanishing between the sheet + and the results. Real result keys always contain "/", so a bare + name cannot collide with one. + """ + + def __init__(self, config, store): + self.config = config + self.store = store + + @staticmethod + def tested_already(tests, name): + """Whether a real result already answers for this game. + + A download row exists so a game with no file still shows up + rather than vanishing between the sheet and the results. When + an apworld is on disk and was tested, nothing is vanishing - + the link broke, which the download report says, while the file + that world was last tested from is still there and still the + answer. Writing a second row then reports one game twice, and + the two rows collide at promotion because they claim the same + game name. + """ + folded = Name(name).directory + return any( + "/" in key and Name(key.split("/", 1)[0]).directory == folded + for key in tests + ) + + @staticmethod + def detail_for(reason): + return ("apworld could not be downloaded, so it was never tested: " + f"{reason}") + + def clear_stale(self, tests, games, skipped): + """Drop rows for games that downloaded this time. + + Only ever touches entries this class wrote, never a real + generation-test result. + """ + failed = {name for name, _ in skipped} + for game in games: + previous = tests.get(game.name) + if (game.name not in failed and previous is not None + and previous.get("stage") == "download"): + del tests[game.name] + + def note(self, tests, name, reason, tag, now): + """Record one failure, keeping an unchanged row's date. + + A row failing the same way as last time keeps the date of the + attempt that reached that answer and only has its last-checked + stamp moved: a test date that advanced every run would claim a + world was looked at when nothing about it was. + """ + detail = self.detail_for(reason) + previous = tests.get(name) + if (previous is not None and previous.get("stage") == "download" + and previous.get("detail") == detail): + previous["tag"] = tag + previous[StateKeys.LAST_CHECKED_AT] = now + return + tests[name] = TestRecord(name).download_failure( + detail, tag) + + def record(self, games, skipped, tag): + state = self.store.load() + tests = state.setdefault(StateKeys.APWORLD_TESTS, {}) + self.clear_stale(tests, games, skipped) + now = datetime.now(timezone.utc).isoformat() + for name, reason in skipped: + if self.tested_already(tests, name): + tests.pop(name, None) + continue + self.note(tests, name, reason, tag, now) + self.store.save(state) + return state diff --git a/src/apworld_tester/pipeline/batch/failed_report.py b/src/apworld_tester/pipeline/batch/failed_report.py new file mode 100644 index 0000000..83693fa --- /dev/null +++ b/src/apworld_tester/pipeline/batch/failed_report.py @@ -0,0 +1,36 @@ +"""The list of worlds that were tested and did not pass.""" + +from apworld_tester.core.state.keys import StateKeys + + +class FailedReport: + """Written after every run, from the whole accumulated state. + + Not just this run's results: a targeted run touches a couple of + entries and the report must still reflect every other known + failure. Kept out of state.json, which is bookkeeping rather than a + report, so answering the question does not mean writing a query. + """ + + def __init__(self, state, path): + self.state = state + self.path = path + + def failed(self): + tests = self.state.get(StateKeys.APWORLD_TESTS, {}) + return sorted( + ((key, record) for key, record in tests.items() + if record.get("outcome") == "failed"), + key=lambda item: item[1].get("game") or item[0], + ) + + def write(self): + with open(self.path, "w", encoding="utf-8") as handle: + failed = self.failed() + if not failed: + handle.write("No failed apworlds.\n") + return + for key, record in failed: + detail = (record.get("detail") or "").splitlines()[0] + handle.write( + f"{record.get('game') or key} ({key}): {detail}\n") diff --git a/src/apworld_tester/pipeline/batch/fetcher.py b/src/apworld_tester/pipeline/batch/fetcher.py new file mode 100644 index 0000000..5046cab --- /dev/null +++ b/src/apworld_tester/pipeline/batch/fetcher.py @@ -0,0 +1,92 @@ +"""Downloading the worlds a run was asked for but does not have.""" + +import os + +import requests + +from apworld_tester.core.config.secrets import Secrets +from apworld_tester.pipeline.batch.apworld_finder import ApworldFinder +from apworld_tester.pipeline.download.download_run import DownloadRun +from apworld_tester.pipeline.sheet.fetcher import SheetFetcher +from apworld_tester.pipeline.sheet.reader import SheetReader + + +class Fetcher: + """Fills the download directory from the sheet, on demand. + + That directory is a working directory this pipeline fills itself, + from the same sheet a full pass reads - not somewhere a user + points at their own copy. A world never fetched is work not done + yet rather than an error. + """ + + def __init__(self, config, paths, root=None): + self.config = config + self.paths = paths + self.root = root or paths.downloads + self.finder = ApworldFinder(self.root) + + def session(self): + session = requests.Session() + token = Secrets(self.paths).get("GITHUB_TOKEN") + if token: + session.headers["Authorization"] = f"token {token}" + return session + + def listed(self): + """The sheet's rows, fetching the sheet if this box has none.""" + fetcher = SheetFetcher(self.config, self.paths) + if not os.path.exists(self.paths.sheet_html): + print(f"fetching the {fetcher.tab_name} sheet " + f"-> {self.paths.sheet_html}") + fetcher.ensure() + return SheetReader(self.config, self.paths.sheet_html).games() + + @staticmethod + def row_for(term, listed): + """The one sheet row a name selects. + + An exact name wins over a substring, and a term still matching + several rows is refused rather than guessed at - downloading + and testing two worlds because a name was ambiguous is worse + than saying so. + """ + rows = [g for g in listed if term.lower() in g.name.lower()] + exact = [g for g in rows if g.name.casefold() == term.casefold()] + rows = exact or rows + if not rows: + raise LookupError( + f"{term!r} is not on the worlds sheet - nothing to " + "download and nothing to test") + if len(rows) > 1: + names = ", ".join(g.name for g in rows) + raise LookupError(f"{term!r} matches {len(rows)} sheet rows: " + f"{names}. Name one of them exactly.") + return rows[0] + + def report(self, terms): + """Say where each requested world landed, or raise.""" + for term in terms: + found = self.finder.find(only=[term]) + if not found: + raise LookupError( + f"no apworld could be obtained for {term!r}") + for name, path in found: + print(f" {name}: {path}") + + def ensure(self, terms): + """Download whichever of `terms` is not already here.""" + missing = [t for t in terms if not self.finder.find(only=[t])] + if not missing: + return [] + listed = self.listed() + wanted = [self.row_for(term, listed) for term in missing] + print(f"downloading {', '.join(g.name for g in wanted)} " + f"-> {self.root}") + run = DownloadRun(self.config, self.paths, self.session(), + root_directory=self.root) + _, skipped, _ = run.all(wanted) + for name, reason in skipped: + print(f" {name} could not be downloaded: {reason}") + self.report(missing) + return [g.name for g in wanted] diff --git a/src/apworld_tester/pipeline/batch/options.py b/src/apworld_tester/pipeline/batch/options.py new file mode 100644 index 0000000..db3815e --- /dev/null +++ b/src/apworld_tester/pipeline/batch/options.py @@ -0,0 +1,48 @@ +"""What one batch run was asked for, with the gaps filled in.""" + +from apworld_tester.core.state.keys import StateKeys + + +class Options: + """The caller's choices, over config.yaml's defaults. + + `repeats` of None means "whatever config.yaml says". An explicit + count trades confidence for time, and the cache check compares + against it, so a cheap run cannot masquerade as a full one. + """ + + def __init__(self, paths, settings, **asked): + self.paths = paths + self.settings = settings + self.asked = asked + + def chosen(self, key, default): + value = self.asked.get(key) + return default if value is None else value + + def counted(self, key, default): + value = self.asked.get(key) + return default if value is None else int(value) + + def resolve(self): + return { + "root_directory": self.chosen("root_directory", + self.paths.downloads), + "output_directory": self.chosen("output_directory", + self.paths.output), + "state_path": self.chosen("state_path", self.paths.state), + "roms_directory": self.chosen("roms_directory", self.paths.roms), + "jobs": self.chosen("jobs", self.settings.jobs), + "state_key": self.chosen( + "state_key", StateKeys.APWORLD_TESTS), + "companion_range": self.chosen("companion_range", + self.settings.companion_range), + "repeats": self.counted("repeats", self.settings.repeats), + "random_repeats": self.counted("random_repeats", + self.settings.random_repeats), + "spoiler": self.settings.spoiler, + "timeout": self.asked.get("timeout"), + "only": self.asked.get("only"), + "exact_only": bool(self.asked.get("exact_only")), + "core_games": self.asked.get("core_games") or (), + } diff --git a/src/apworld_tester/pipeline/batch/orphaned_records.py b/src/apworld_tester/pipeline/batch/orphaned_records.py new file mode 100644 index 0000000..12cb21a --- /dev/null +++ b/src/apworld_tester/pipeline/batch/orphaned_records.py @@ -0,0 +1,57 @@ +"""Dropping test records whose apworld is no longer on disk.""" + +import os + +from apworld_tester.core.state.keys import StateKeys + + +class OrphanedRecords: + """Results for files that have since gone. + + A record is keyed on the path its apworld had under the downloads + root, so a file that is removed - a release that renamed its asset, + a folder cleared by hand - leaves its result behind. Nothing reads + that record as stale: it still names a tag and a verdict, so the + status page goes on showing the world, and where the replacement + file sits beside it the game is reported twice. + + Only records with a file behind them are considered. A download- or + core-stage record is keyed on the bare sheet name and never had one, + and Discontinued is what retires those. + """ + + def __init__(self, store, downloads): + self.store = store + self.downloads = downloads + + def orphaned(self, tests): + """Every keyed-on-a-path record whose file is missing.""" + return [key for key, record in tests.items() + if "/" in key + and (record or {}).get("stage") not in ("download", "core") + and not os.path.isfile(os.path.join(self.downloads, key))] + + def safe(self): + """Whether the downloads folder looks like itself. + + An unreadable or empty root would make every record an orphan + and empty the page, which is the one outcome worth refusing: + a genuinely empty downloads directory has nothing to prune for + anyway, so declining costs nothing and guessing costs the lot. + """ + if not os.path.isdir(self.downloads): + return False + return any(os.scandir(self.downloads)) + + def prune(self): + """Remove them, returning the keys that went.""" + if not self.safe(): + return [] + state = self.store.load() + tests = state.get(StateKeys.APWORLD_TESTS, {}) + gone = self.orphaned(tests) + for key in gone: + del tests[key] + if gone: + self.store.save(state) + return gone diff --git a/src/apworld_tester/pipeline/batch/planner.py b/src/apworld_tester/pipeline/batch/planner.py new file mode 100644 index 0000000..c7985de --- /dev/null +++ b/src/apworld_tester/pipeline/batch/planner.py @@ -0,0 +1,104 @@ +"""Splitting the work into known results and tests still to run.""" + +from apworld_tester.core.model.blacklist import Blacklist +from apworld_tester.core.model.name import Name +from apworld_tester.core.state.keys import StateKeys +from apworld_tester.pipeline.batch.cache_check import CacheCheck +from apworld_tester.pipeline.batch.record import TestRecord + + +class Planner: + """What is already answered, and what has to be generated. + + The blacklist is checked before the cache and is unaffected by a + force flag: "do not retry" is the entire reason a world is listed, + so a flag that retried it would defeat the point. + """ + + def __init__(self, config, settings, tests, tag, roms_fingerprint, + force, repeats, random_repeats, companion_range, + releases=None): + self.config = config + self.settings = settings + self.tests = tests + self.tag = tag + self.roms_fingerprint = roms_fingerprint + self.force = force + self.repeats = repeats + self.random_repeats = random_repeats + self.companion_range = companion_range + self.blacklist = Blacklist(config) + # Keyed on the folded name: the work list names a row by + # its download folder, while game_releases keys on the + # sheet's own wording, and the two differ wherever the + # sheet uses a colon or its own capitalisation. + self.releases = { + Name(name).directory: (release or {}).get("published_at") + for name, release in (releases or {}).items() + } + self.wrote_state = False + + def record_for(self, item): + game_name, apworld_path, key, _ = item + return TestRecord(game_name, key, + is_core=apworld_path is None) + + def decline(self, item): + """Write and return the result for a declined world.""" + _, _, key, current_hash = item + record = self.record_for(item) + self.tests[key] = record.blacklisted( + blacklist=self.blacklist, + current_hash=current_hash, + tag=self.tag, + roms_fingerprint=self.roms_fingerprint, + ) + self.wrote_state = True + return record.as_result(self.tests[key]) + + def confirm(self, previous): + """Note that this answer was looked at and still holds. + + A cached result is not re-run, so its tested_at stays at the + run that reached it - which on a page refreshed today reads as + a world nobody has looked at in weeks rather than as a result + nothing has been able to change. This is the date that says + which of the two it is, and only a download failure was + recording it, so it was missing from every row that passed. + """ + previous[StateKeys.LAST_CHECKED_AT] = TestRecord.now() + self.wrote_state = True + + def known(self, item): + """This world's answer already, or None if it must be tested.""" + game_name, _, key, current_hash = item + if self.blacklist.listed(game_name): + return self.decline(item) + previous = self.tests.get(key) + cache = CacheCheck(self.settings, previous) + served = not self.force and cache.serves( + current_hash=current_hash, + tag=self.tag, + roms_fingerprint=self.roms_fingerprint, + repeats=self.repeats, + random_repeats=self.random_repeats, + companion_range=self.companion_range, + # A core world has no release of its own here, so this + # is None for one and the rule cannot fire: what dates + # it is the Archipelago build, already compared above. + released_at=self.releases.get(game_name), + ) + if not served: + return None + self.confirm(previous) + return self.record_for(item).as_result(previous) + + def plan(self, work): + results, to_test = [], [] + for item in work: + answer = self.known(item) + if answer is None: + to_test.append(item) + else: + results.append(answer) + return results, to_test diff --git a/src/apworld_tester/pipeline/batch/record.py b/src/apworld_tester/pipeline/batch/record.py new file mode 100644 index 0000000..12888a8 --- /dev/null +++ b/src/apworld_tester/pipeline/batch/record.py @@ -0,0 +1,111 @@ +"""The state entry written for one world.""" + +from datetime import datetime, timezone + +from apworld_tester.core.model.blacklist import Blacklist +from apworld_tester.core.state.keys import StateKeys + + +class TestRecord: + """What a finished, declined or undownloadable world records. + + A core world has no file behind it, so it is marked as such and + carries no apworld path. + """ + + def __init__(self, game_name, key=None, is_core=False): + self.game_name = game_name + self.key = key + self.is_core = is_core + + @staticmethod + def now(): + return datetime.now(timezone.utc).isoformat() + + def cored(self, record): + if self.is_core: + record["stage"] = "core" + record["apworld"] = None + return record + + def from_report(self, report, current_hash, tag, internal_game, + roms_fingerprint): + """One finished test. + + Single and multi verdicts stay separate: different questions. + Every mode's own verdict is kept too, so adding one needs no + change here. "repaired" is true when the file had to be fixed + before it would load - a real result, but for a corrected copy + rather than the file the author published. + """ + return self.cored({ + "hash": current_hash, + "tag": tag, + "game": internal_game, + "outcome": report.get("outcome"), + "detail": report.get("detail"), + "elapsed_seconds": report.get("elapsed_seconds"), + "multi_outcome": report.get("multi_outcome"), + "multi_detail": report.get("multi_detail"), + "multi_elapsed_seconds": report.get("multi_elapsed_seconds"), + "multi_companions": report.get("multi_companions"), + "tests": report.get("tests"), + "roms_fingerprint": roms_fingerprint, + "tested_at": self.now(), + "repaired": bool(report.get("repaired")), + }) + + def blacklisted(self, blacklist, current_hash, tag, + roms_fingerprint): + """A world this pipeline declines to test.""" + return self.cored({ + "hash": current_hash, + "tag": tag, + "game": self.game_name, + "outcome": Blacklist.OUTCOME, + "detail": blacklist.DETAIL, + "elapsed_seconds": None, + "multi_outcome": None, + "multi_detail": None, + "multi_elapsed_seconds": None, + "multi_companions": None, + "tests": None, + "roms_fingerprint": roms_fingerprint, + "tested_at": self.now(), + "blacklisted": True, + }) + + def download_failure(self, detail, tag): + """A game whose apworld could not be fetched. + + "unknown", never "failed": nothing was tested, so nothing is + known to be broken. Whether the link was a Discord message, an + unsupported host, or a release with no apworld in it, the + conclusion is the same - it could not be checked. + """ + return { + "game": self.game_name, + "apworld": None, + "outcome": "unknown", + "detail": detail, + "elapsed_seconds": None, + "stage": "download", + "tag": tag, + "hash": None, + "tested_at": self.now(), + StateKeys.LAST_CHECKED_AT: self.now(), + } + + def as_result(self, record): + """One entry of the caller-facing result list.""" + return { + "game": self.game_name, + "apworld": None if self.is_core else self.key, + "outcome": record.get("outcome"), + "detail": record.get("detail"), + "elapsed_seconds": record.get("elapsed_seconds"), + "multi_outcome": record.get("multi_outcome"), + "multi_detail": record.get("multi_detail"), + "tests": record.get("tests"), + "skipped": True, + } diff --git a/src/apworld_tester/pipeline/batch/reporter.py b/src/apworld_tester/pipeline/batch/reporter.py new file mode 100644 index 0000000..0102916 --- /dev/null +++ b/src/apworld_tester/pipeline/batch/reporter.py @@ -0,0 +1,49 @@ +"""Saying what each finished world did.""" + +from apworld_tester.core.display.progress import Progress +from apworld_tester.core.model.verdict import Verdict + + +class Reporter: + """One run's progress callback. + + Holds its own bar rather than a module variable: two runs in one + process would otherwise share a bar and a tally. + """ + + def __init__(self, config, bar=False): + self.config = config + self.wants_bar = bar + self.bar = None + + @staticmethod + def marker(report): + return ("cached" if report.get("skipped") + else f"{report.get('elapsed_seconds')}s") + + def verdicts(self, report): + text = f"{report['outcome']}" + multi = report.get("multi_outcome") + return f"{text} / multi:{multi}" if multi else text + + def drawn(self, index, total, report): + if self.bar is None or self.bar.total != total: + self.bar = Progress(total=total, config=self.config) + self.bar.update( + index=index, + key=report.get("outcome") or "?", + text=report.get("game") or "", + fallback_line=(f"[{index}/{total}] {report['game']}: " + f"{self.verdicts(report)} ({self.marker(report)})"), + ) + + def printed(self, index, total, report): + print(f"[{index}/{total}] {report.get('game')}: " + f"{Verdict(report, self.config).label} ({self.marker(report)})", + flush=True) + + def __call__(self, index, total, report): + if self.wants_bar: + self.drawn(index, total, report) + else: + self.printed(index, total, report) diff --git a/src/apworld_tester/pipeline/batch/results.py b/src/apworld_tester/pipeline/batch/results.py new file mode 100644 index 0000000..4578b34 --- /dev/null +++ b/src/apworld_tester/pipeline/batch/results.py @@ -0,0 +1,95 @@ +"""The reports one run produced.""" + +from apworld_tester.core.model.modes import Modes +from apworld_tester.core.model.verdict import Verdict + + +class Results(list): + """A run's reports, printable as a table. + + A plain list of the same dicts the batch returns, so anything that + worked on those still works. The repr is the addition, because the + first thing anyone does with these is look at them. + """ + + def __init__(self, reports, config): + super().__init__(reports) + self.config = config + + def verdict(self, report): + return Verdict(report, self.config).name + + @property + def counts(self): + tally = {} + for report in self: + name = self.verdict(report) + tally[name] = tally.get(name, 0) + 1 + return dict(sorted(tally.items(), key=lambda item: -item[1])) + + @property + def summary(self): + """Outcome counts, multi verdicts under their own prefix. + + They are a separate question, so they are not merged into one + total. + """ + counts = {} + for report in self: + outcome = report.get("outcome") + counts[outcome] = counts.get(outcome, 0) + 1 + multi = report.get("multi_outcome") + if multi: + counts[f"multi:{multi}"] = counts.get(f"multi:{multi}", 0) + 1 + return counts + + #: Best to worst, so the shape of a run reads top to bottom. + LADDER = ("stable", "unstable", "flaky", "solo_only", "broken", + "unknown") + + @property + def overview(self): + """The run's verdicts, in the words the sheet uses. + + The counts alone answer "what happened", but only in this + pipeline's vocabulary; a reader comparing against the sheet + needs the labels, and a reader watching a run needs them in a + fixed order rather than sorted by however this run landed. + """ + labels = self.config.value("stability", "labels", {}) + counts = self.counts + names = [name for name in self.LADDER if name in counts] + names += [name for name in counts if name not in self.LADDER] + width = max((len(labels.get(name, name)) for name in names), + default=0) + lines = [f"{len(self)} worlds tested", ""] + lines += [ + f" {labels.get(name, name):{width}s} {counts[name]:>4d}" + for name in names + ] + return "\n".join(lines) + + def row(self, report, width): + """One world's line: name, verdict, every mode's outcome.""" + mode_results = report.get("tests") or {} + per_mode = " ".join( + f"{mode}={(mode_results.get(mode) or {}).get('outcome') or '-'}" + for mode in Modes.ALL + ) + marker = " (cached)" if report.get("skipped") else "" + return (f"{report.get('game') or '?':{width}s} " + f"{Verdict(report, self.config).label:12s} " + f"{per_mode}{marker}") + + def __repr__(self): + if not self: + return "no worlds tested" + width = max(len(report.get("game") or "") for report in self) + lines = [ + self.row(report, width) + for report in sorted(self, key=lambda r: r.get("game") or "") + ] + if len(self) > 1: + lines += ["", " ".join(f"{name}: {count}" + for name, count in self.counts.items())] + return "\n".join(lines) diff --git a/src/apworld_tester/pipeline/batch/run.py b/src/apworld_tester/pipeline/batch/run.py new file mode 100644 index 0000000..d96d9b9 --- /dev/null +++ b/src/apworld_tester/pipeline/batch/run.py @@ -0,0 +1,92 @@ +"""Testing worlds by hand, from the notebook or a shell.""" + +from apworld_tester.core.config.config import Config +from apworld_tester.core.config.paths import Paths +from apworld_tester.core.config.run_settings import RunSettings +from apworld_tester.core.state.lock import PipelineLock +from apworld_tester.core.state.scratch import ScratchState +from apworld_tester.core.state.store import StateStore +from apworld_tester.pipeline.batch.apworld_finder import ApworldFinder +from apworld_tester.pipeline.batch.batch import Batch +from apworld_tester.pipeline.batch.fetcher import Fetcher +from apworld_tester.pipeline.batch.reporter import Reporter +from apworld_tester.pipeline.batch.results import Results +from apworld_tester.pipeline.batch.version import Version +from apworld_tester.pipeline.build.archipelago import ArchipelagoBuild + + +class TestRun: + """Test one world, several, or every downloaded one. + + `live` records into the real state file - off by default, because a + hand-run test is an experiment. `cached` only affects a whole-set + run; a named world is always retested. + """ + + def __init__(self, game=None, repeats=None, random_repeats=None, jobs=2, + live=False, cached=False, root_directory=None, quiet=False, + update=False): + self.config = Config.load() + self.paths = Paths(self.config) + self.settings = RunSettings(self.config) + self.game = game + self.repeats = repeats + self.random_repeats = random_repeats + self.jobs = jobs + self.live = live + self.cached = cached + self.root = root_directory or self.paths.downloads + self.quiet = quiet + self.update = update + + @property + def state_path(self): + return ScratchState(self.paths, live=self.live).path + + def worlds(self): + """The folders to test, downloading any that are missing. + + Resolved to real folder names and matched exactly from here on, + so the set tested is the set that was just named. + """ + if self.game is None: + return None + terms = [self.game] if isinstance(self.game, str) else list(self.game) + Fetcher(self.config, self.paths, self.root).ensure(terms) + finder = ApworldFinder(self.root) + return [name for term in terms for name in finder.resolve(term)] + + def announce(self, tag, only): + """What is about to run, and nothing else.""" + named = ", ".join(only) if only else "every downloaded apworld" + print(f"archipelago {tag}") + print(f"testing {named}") + + def run(self): + """The reports, printable as a table.""" + store = StateStore(self.paths.state) + build = ArchipelagoBuild(self.config, self.paths) + tag, _ = Version(self.config, self.paths, store).prepare( + update=self.update) + only = self.worlds() + build.ensure_test_image(tag) + state_path = self.state_path + if not self.quiet: + self.announce(tag, only) + with PipelineLock(self.paths.lock): + return Results(Batch( + config=self.config, + paths=self.paths, + settings=self.settings, + image=build.test_image_tag(tag), + tag=tag, + force=not self.cached, + on_progress=None if self.quiet else Reporter(self.config), + root_directory=self.root, + state_path=state_path, + only=only, + exact_only=only is not None, + jobs=self.jobs, + repeats=self.repeats, + random_repeats=self.random_repeats, + ).run(), self.config) diff --git a/src/apworld_tester/pipeline/batch/runner.py b/src/apworld_tester/pipeline/batch/runner.py new file mode 100644 index 0000000..73e8b1e --- /dev/null +++ b/src/apworld_tester/pipeline/batch/runner.py @@ -0,0 +1,86 @@ +"""Running the tests the cache could not answer.""" + +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed + +from apworld_tester.pipeline.batch.record import TestRecord +from apworld_tester.pipeline.generation.tester import WorldTester + + +class Runner: + """Tests each outstanding world and persists what it did.""" + + def __init__(self, config, paths, store, options, state, tests, tag, + image, roms_fingerprint): + self.config = config + self.paths = paths + self.store = store + self.options = options + self.state = state + self.tests = tests + self.tag = tag + self.tester = WorldTester(config, paths, image) + self.roms_fingerprint = roms_fingerprint + self.lock = threading.Lock() + + @staticmethod + def promotion_name(report, game_name): + """The name Archipelago itself registered this world under. + + Recorded for promotion's collision check, while the report's + own "game" becomes the sheet folder name for display. A world + that never loaded has no real name, so it falls back to a + marked sheet name - otherwise every such entry would collide + under one None key and none would promote. + """ + return report.get("game") or f"unverified:{game_name}" + + def store_record(self, key, record): + """Persist after every test, not just at the end, so a killed + run does not lose the tests it already completed. + """ + with self.lock: + self.tests[key] = record + self.store.save(self.state) + + def test_one(self, game_name, apworld_path, key, current_hash): + options = self.options + report = self.tester.test( + apworld_path=apworld_path, + game=None if apworld_path else game_name, + output_dir=options["output_directory"], + timeout=options["timeout"], + companion_range=options["companion_range"], + spoiler=options["spoiler"], + repeats=options["repeats"], + companion_pool=options["pool"], + random_repeats=options["random_repeats"], + ) + record = TestRecord(game_name, key, + is_core=apworld_path is None) + self.store_record(key, record.from_report( + report=report, + current_hash=current_hash, + tag=self.tag, + internal_game=self.promotion_name(report, game_name), + roms_fingerprint=self.roms_fingerprint, + )) + report["game"] = game_name + report["apworld"] = key + report["skipped"] = False + return report + + def run(self, to_test, total, completed, on_progress): + """Run the outstanding tests, reporting as they land.""" + results = [] + reporting = threading.Lock() + with ThreadPoolExecutor(max_workers=self.options["jobs"]) as pool: + futures = [pool.submit(self.test_one, *args) for args in to_test] + for future in as_completed(futures): + report = future.result() + results.append(report) + with reporting: + completed += 1 + if on_progress: + on_progress(completed, total, report) + return results diff --git a/src/apworld_tester/pipeline/batch/session.py b/src/apworld_tester/pipeline/batch/session.py new file mode 100644 index 0000000..21f25ad --- /dev/null +++ b/src/apworld_tester/pipeline/batch/session.py @@ -0,0 +1,92 @@ +"""Testing worlds by hand, from the notebook or a shell.""" + +from apworld_tester.core.config.config import Config +from apworld_tester.core.config.paths import Paths +from apworld_tester.core.config.run_settings import RunSettings +from apworld_tester.core.state.lock import PipelineLock +from apworld_tester.core.state.scratch import ScratchState +from apworld_tester.core.state.store import StateStore +from apworld_tester.pipeline.batch.apworld_finder import ApworldFinder +from apworld_tester.pipeline.batch.batch import Batch +from apworld_tester.pipeline.batch.fetcher import Fetcher +from apworld_tester.pipeline.batch.reporter import Reporter +from apworld_tester.pipeline.batch.results import Results +from apworld_tester.pipeline.batch.version import Version +from apworld_tester.pipeline.build.archipelago import ArchipelagoBuild + + +class TestSession: + """Test one world, several, or every downloaded one. + + `live` records into the real state file - off by default, because a + hand-run test is an experiment. `cached` only affects a whole-set + run; a named world is always retested. + """ + + def __init__(self, game=None, repeats=None, random_repeats=None, jobs=2, + live=False, cached=False, root_directory=None, quiet=False, + update=False): + self.config = Config.load() + self.paths = Paths(self.config) + self.settings = RunSettings(self.config) + self.game = game + self.repeats = repeats + self.random_repeats = random_repeats + self.jobs = jobs + self.live = live + self.cached = cached + self.root = root_directory or self.paths.downloads + self.quiet = quiet + self.update = update + + @property + def state_path(self): + return ScratchState(self.paths, live=self.live).path + + def worlds(self): + """The folders to test, downloading any that are missing. + + Resolved to real folder names and matched exactly from here on, + so the set tested is the set that was just named. + """ + if self.game is None: + return None + terms = [self.game] if isinstance(self.game, str) else list(self.game) + Fetcher(self.config, self.paths, self.root).ensure(terms) + finder = ApworldFinder(self.root) + return [name for term in terms for name in finder.resolve(term)] + + def announce(self, tag, only): + """What is about to run, and nothing else.""" + named = ", ".join(only) if only else "every downloaded apworld" + print(f"archipelago {tag}") + print(f"testing {named}") + + def run(self): + """The reports, printable as a table.""" + store = StateStore(self.paths.state) + build = ArchipelagoBuild(self.config, self.paths) + tag, _ = Version(self.config, self.paths, store).prepare( + update=self.update) + only = self.worlds() + build.ensure_test_image(tag) + state_path = self.state_path + if not self.quiet: + self.announce(tag, only) + with PipelineLock(self.paths.lock): + return Results(Batch( + config=self.config, + paths=self.paths, + settings=self.settings, + image=build.test_image_tag(tag), + tag=tag, + force=not self.cached, + on_progress=None if self.quiet else Reporter(self.config), + root_directory=self.root, + state_path=state_path, + only=only, + exact_only=only is not None, + jobs=self.jobs, + repeats=self.repeats, + random_repeats=self.random_repeats, + ).run(), self.config) diff --git a/src/apworld_tester/pipeline/batch/version.py b/src/apworld_tester/pipeline/batch/version.py new file mode 100644 index 0000000..c3fe63b --- /dev/null +++ b/src/apworld_tester/pipeline/batch/version.py @@ -0,0 +1,87 @@ +"""Which Archipelago version a run tests against.""" + +from apworld_tester.core.state.keys import StateKeys +from apworld_tester.pipeline.build.archipelago import ArchipelagoBuild +from apworld_tester.pipeline.build.docker import Docker + + +class Version: + """Resolve the version, and build it if this machine lacks it.""" + + def __init__(self, config, paths, store): + self.paths = paths + self.store = store + self.build = ArchipelagoBuild(config, paths) + + def resolve(self): + """The version to test, and where that choice came from. + + A pin is a decision already made, so nothing else revisits it. + Failing that the recorded version wins, then whichever image is + on this machine - a test runs entirely inside the container, so + an image already here is everything it needs. + """ + pinned = self.build.pinned_version + if pinned: + return pinned, "pinned in config.yaml" + recorded = self.store.load().get(StateKeys.ARCHIPELAGO_TAG) + if recorded: + return recorded, "from state.json" + local = next(iter(self.build.local_tags()), None) + return (local, "newest image on this machine") if local else (None, + None) + + def ensure_built(self, tag): + """Build what this version needs, if anything is missing. + + Building needs the Archipelago source, which is the one job the + checkout has - so this is also the only path that clones. + """ + if tag is None: + print("no Archipelago image on this machine - cloning the " + "newest release to build one (several minutes) ...") + built, _, _, _ = self.build.update_and_build(self.store) + return built, "just built" + if not Docker.image_exists(self.build.image_tag(tag)): + print(f"Archipelago {tag} is not built on this machine - " + "cloning and building it (several minutes) ...") + self.build.build_version(tag) + return tag, None + + def note_others(self, tag, source): + """Say so when an unpinned run is not using the newest image. + + An unpinned tag can come from an image that happens to be lying + around, so a run can quietly answer for a version that is not + the current one. A pinned tag was chosen deliberately. + """ + local = self.build.local_tags() + if self.build.pinned_version or not local or tag == local[0]: + return source + return f"{source}, but {local[0]} is also built here" + + def update(self): + """Resolve the newest release, build it, and record it. + + Never automatic. A new version invalidates every cached result, + so the full re-test it forces is the caller's decision, not a + side effect of running. A pinned version stays pinned: the + resolution behind this honours the pin, so an update then only + ensures that version is built. + """ + tag, previous, changed, _ = self.build.update_and_build(self.store) + if self.build.pinned_version: + return tag, "pinned in config.yaml" + if not changed: + return tag, "already the newest release" + if previous is None: + return tag, "newest release, recorded for the first time" + return tag, f"updated from {previous}" + + def prepare(self, update=False): + """The version to test against, built if it is missing.""" + if update: + return self.update() + tag, source = self.resolve() + tag, built = self.ensure_built(tag) + return tag, built or self.note_others(tag, source) diff --git a/src/apworld_tester/pipeline/batch/work_list.py b/src/apworld_tester/pipeline/batch/work_list.py new file mode 100644 index 0000000..80e45d2 --- /dev/null +++ b/src/apworld_tester/pipeline/batch/work_list.py @@ -0,0 +1,41 @@ +"""Everything one run will consider testing.""" + +import os + +from apworld_tester.core.model.fingerprint import Fingerprint +from apworld_tester.pipeline.batch.apworld_finder import ApworldFinder + + +class WorkList: + """Each world to consider, with its cache key and hash. + + A core world is tested by name with no file behind it, so it is + keyed on the bare sheet name - the same "no slash means no apworld" + convention download failures follow - and gets a stand-in hash + whose real content is the Archipelago build beside it. + """ + + #: Stands in for a core world's file hash. Its real content is + #: the Archipelago build, so the version compared beside it in the + #: cache check is what decides staleness. + CORE_HASH = "core" + + def __init__(self, root, only=None, exact=False, core_games=()): + self.root = root + self.only = only + self.exact = exact + self.core_games = core_games + + def items(self): + found = ApworldFinder(self.root).find(only=self.only, + exact=self.exact) + work = [ + (name, path, os.path.relpath(path, self.root), + Fingerprint.of_file(path)) + for name, path in found + ] + return work + [ + (name, None, name, WorkList.CORE_HASH) + for name in self.core_games + if self.only is None or name in self.only + ] diff --git a/src/apworld_tester/pipeline/download/__init__.py b/src/apworld_tester/pipeline/download/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apworld_tester/pipeline/download/asset_matcher.py b/src/apworld_tester/pipeline/download/asset_matcher.py new file mode 100644 index 0000000..80e8819 --- /dev/null +++ b/src/apworld_tester/pipeline/download/asset_matcher.py @@ -0,0 +1,82 @@ +"""Choosing which asset in a release belongs to this game.""" + +import re + +from apworld_tester.core.model.name import Name + + +class AssetMatcher: + """Matches release assets and release titles to one sheet row.""" + + #: Title slugs that name no game at all, so they are never read as + #: evidence that a release belongs to a different sheet row. Not + #: configuration: they are the words every release title uses about + #: itself, and a release titled "Apworld Release" claims nobody. + UNINFORMATIVE = frozenset({ + "archipelago", + "apworld", + "release", + "latest", + "update", + }) + + def __init__(self, config, game_name): + self.config = config + self.game = Name(game_name) + + def named(self, assets, exact): + """The assets whose filename names this game.""" + matched = [] + for asset in assets: + name = Name(asset["name"].rsplit(".", 1)[0]).slug + if (name == self.game.slug) if exact else (name in self.game.slug): + matched.append(asset) + return matched + + def select(self, assets): + """The assets to download for this game.""" + if len(assets) <= 1: + return assets + for matches in (self.named(assets, True), self.named(assets, False)): + if len(matches) == 1: + return matches + return assets + + def title_slug(self, release): + """A release's own title, reduced to the game it is for.""" + title = release.get("name") or release.get("tag_name") or "" + title = re.sub(r"\bv?\d+(?:\.\d+)*\b", " ", title) + title = re.sub(r"\bapworld\b", " ", title, flags=re.IGNORECASE) + return Name(title).slug + + def claimed_by(self, title, sheet_names): + """The other sheet row this title names, or None. + + Exact equality only. This is not "the title looks a bit off" - + it is positive evidence that a DIFFERENT row is named, which is + the only thing strong enough to skip a release the link points + at. "Mega Man X2" is exactly a row and is skipped when resolving + "Mega Man X"; "Mega Man X1" is nobody's row and is accepted. + """ + if not sheet_names or not self.game.text: + return None + if not title or title in self.UNINFORMATIVE: + return None + if title == self.game.slug: + return None + for other in sheet_names: + slug = Name(other).slug + if slug != self.game.slug and slug == title: + return other + return None + + def release_claimed(self, release, sheet_names): + return self.claimed_by(self.title_slug(release), sheet_names) + + def identity_claimed(self, found_games, sheet_names): + """Whether a downloaded file identifies as another row's game.""" + for found in found_games or []: + other = self.claimed_by(Name(found).slug, sheet_names) + if other: + return other + return None diff --git a/src/apworld_tester/pipeline/download/auth_error.py b/src/apworld_tester/pipeline/download/auth_error.py new file mode 100644 index 0000000..10dd5bb --- /dev/null +++ b/src/apworld_tester/pipeline/download/auth_error.py @@ -0,0 +1,23 @@ +"""A token failure, which is the whole run's problem.""" + +import requests + + +class GitHubAuthError(RuntimeError): + """A bad or expired token affects every remaining request.""" + + @classmethod + def matching(cls, game, error): + """This error as an auth failure, or None if it is not one. + + Raised loudly so the whole pool stops, rather than quietly + failing game after game against a token that will not work. + """ + if not isinstance(error, requests.HTTPError): + return None + response = error.response + status = response.status_code if response is not None else None + if status not in (401, 403): + return None + return cls(f"GitHub rejected the request for {game.name} with " + f"{status} - check .env / token expiry.") diff --git a/src/apworld_tester/pipeline/download/context.py b/src/apworld_tester/pipeline/download/context.py new file mode 100644 index 0000000..9d361db --- /dev/null +++ b/src/apworld_tester/pipeline/download/context.py @@ -0,0 +1,58 @@ +"""What every download shares.""" + +import os + +from apworld_tester.core.model.name import Name +from apworld_tester.pipeline.download.asset_matcher import AssetMatcher + + +class DownloadContext: + """Who is downloading what, and where it goes. + + One object rather than the same six arguments threaded through + every handler and helper. + """ + + def __init__(self, config, paths, session, game, identifier=None, + sheet_names=None, root_directory=None): + self.config = config + self.paths = paths + self.session = session + self.game = game + self.identifier = identifier + self.sheet_names = sheet_names + self.root = root_directory or paths.downloads + self.matcher = AssetMatcher(config, game.name) + + @property + def target_directory(self): + return os.path.join(self.root, Name(self.game.name).directory) + + def target_path(self, filename): + return os.path.join(self.target_directory, filename) + + @property + def plain_headers(self): + """Headers for a host that is not GitHub. + + The session carries a GitHub token, which must never be sent to + an unrelated site. + """ + return {"Authorization": None, "Accept": "*/*"} + + @property + def json_headers(self): + """Headers for another forge's JSON API, without the token.""" + return {"Authorization": None, "Accept": "application/json"} + + def wrong_game(self, path): + """Why this file is another game's, or None to keep it.""" + if self.identifier is None: + return None + identity = self.identifier.identify(path) + if not identity.get("ran"): + return None + found = identity.get("games") or [] + if any(Name(self.game.name).matches(name) for name in found): + return None + return found or identity.get("detail") diff --git a/src/apworld_tester/pipeline/download/download_run.py b/src/apworld_tester/pipeline/download/download_run.py new file mode 100644 index 0000000..78ea2f3 --- /dev/null +++ b/src/apworld_tester/pipeline/download/download_run.py @@ -0,0 +1,156 @@ +"""Fetching every game's apworld, concurrently.""" + +import os +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed + +import requests + +from apworld_tester.core.state.keys import StateKeys +from apworld_tester.core.state.store import StateStore +from apworld_tester.pipeline.download.auth_error import GitHubAuthError +from apworld_tester.pipeline.download.context import DownloadContext +from apworld_tester.pipeline.download.downloader import Downloader +from apworld_tester.pipeline.download.superseded_assets import ( + SupersededAssets, +) + + +class DownloadRun: + """One pass over the sheet's rows. + + `sheet_names` should be the FULL sheet: a file can identify as a + core row, which never reaches here. + """ + + def __init__(self, config, paths, session, root_directory=None, + identifier=None, jobs=None, sheet_names=None, + previous_releases=None): + self.config = config + self.paths = paths + self.session = session + self.root = root_directory or paths.downloads + self.identifier = identifier + self.jobs = jobs or int(config.value("general", "threads", 4)) + self.sheet_names = sheet_names + self.previous_releases = previous_releases or {} + + @staticmethod + def split(games): + """The games worth a network call, and those answered now. + + A row with no link has nothing to look up, so it never takes a + slot in the thread pool. + """ + downloadable, skipped = [], [] + for game in games: + if not game.release and not game.links: + skipped.append((game.name, "no release link in the sheet")) + else: + downloadable.append(game) + return downloadable, skipped + + def context_for(self, game): + return DownloadContext( + config=self.config, + paths=self.paths, + session=self.session, + game=game, + identifier=self.identifier, + sheet_names=self.sheet_names, + root_directory=self.root, + ) + + def one(self, game): + """Download one game, which never raises for that game alone. + + A network failure is that game's own result, not the run's - + except an auth failure, which is everyone's. + """ + try: + downloaded, reason, info = Downloader( + context=self.context_for(game), + previous=self.previous_releases.get(game.name), + ).run() + except requests.RequestException as error: + auth = GitHubAuthError.matching(game, error) + if auth is not None: + raise auth from error + return game, None, str(error), None + return game, downloaded, reason, info + + @staticmethod + def source_url(game): + """Where this apworld came from, for the status page. + + game.release is only ever a real releases link, so for anything + resolved through the plain links the first one stands in. + """ + return game.release or (game.links[0] if game.links else None) + + def record(self, finished, releases, skipped, completed, total, + on_asset, on_game): + """Record one finished game and tell the caller about it. + + The release is recorded even on failure - it was still found, + so its version is worth showing. on_game is called for failures + too, unlike on_asset: a progress bar has to count every game + that finished, not only the ones that produced a file. + """ + game, downloaded, reason, info = finished + releases[game.name] = {"url": self.source_url(game), **(info or {})} + if downloaded is None: + skipped.append((game.name, reason)) + if on_game: + on_game(completed, total, game.name, False) + return 0 + paths = [path for path, _ in downloaded] + for path in SupersededAssets(os.path.dirname(paths[0]), + paths).prune(): + print(f"{game.name}: removed superseded {os.path.basename(path)}" + f" - the release no longer offers it") + for path, status in downloaded: + if on_asset: + on_asset(game.name, path, status) + if on_game: + on_game(completed, total, game.name, True) + return len(downloaded) + + @staticmethod + def result_of(future, executor): + """One finished download, stopping the pool on auth failure.""" + try: + return future.result() + except GitHubAuthError: + executor.shutdown(cancel_futures=True) + raise + + def all(self, games, on_asset=None, on_game=None): + """Every game's apworld: how many files, what was skipped, and + which release each row resolved to. + + Recording holds a lock, so on_asset always runs serialized and + a caller's own progress counter needs none of its own. + """ + self.sheet_names = self.sheet_names or [g.name for g in games] + downloadable, skipped = self.split(games) + releases, lock = {}, threading.Lock() + total = completed = 0 + with ThreadPoolExecutor(max_workers=self.jobs) as executor: + futures = [executor.submit(self.one, g) for g in downloadable] + for future in as_completed(futures): + finished = self.result_of(future, executor) + with lock: + completed += 1 + total += self.record( + finished, releases, skipped, completed, + len(downloadable), on_asset, on_game) + return total, skipped, releases + + def record_releases(self, releases, state_path=None): + """Write each game's resolved release into the state file.""" + store = StateStore(state_path or self.paths.state) + state = store.load() + state.setdefault(StateKeys.GAME_RELEASES, {}).update(releases) + store.save(state) + return state diff --git a/src/apworld_tester/pipeline/download/downloader.py b/src/apworld_tester/pipeline/download/downloader.py new file mode 100644 index 0000000..0ec9947 --- /dev/null +++ b/src/apworld_tester/pipeline/download/downloader.py @@ -0,0 +1,75 @@ +"""Fetching one sheet row's apworld.""" + +from apworld_tester.pipeline.download.handlers.gitea_release import ( + GiteaRelease, +) +from apworld_tester.pipeline.download.handlers.github_file import ( + GitHubFile, +) +from apworld_tester.pipeline.download.handlers.github_release import ( + GitHubRelease, +) +from apworld_tester.pipeline.download.handlers.github_repo import ( + GitHubRepo, +) +from apworld_tester.pipeline.download.handlers.gitlab_file import ( + GitLabFile, +) +from apworld_tester.pipeline.download.handlers.gitlab_release import ( + GitLabRelease, +) +from apworld_tester.pipeline.download.handlers.plain_file import PlainFile +from apworld_tester.pipeline.download.unusable_links import UnusableLinks +from apworld_tester.pipeline.download.url_shapes import UrlShapes + + +class Downloader: + """Whatever shape this game's link is, get the apworld. + + A releases link is resolved directly; anything else is tried + against every handler in turn, most specific first, so an exact + file beats browsing a whole repo and the plain-URL handler can + never shadow a forge-specific one. + """ + + def __init__(self, context, previous=None): + self.context = context + self.shapes = UrlShapes(context.config) + self.previous = previous + + def handlers(self): + return ( + GitHubFile(self.shapes), + GitHubRepo(self.shapes), + GitLabFile(self.shapes), + GitLabRelease(self.shapes), + GiteaRelease(self.shapes, previous=self.previous), + PlainFile(self.shapes), + ) + + def from_release_link(self): + handler = GitHubRelease(self.shapes, previous=self.previous) + parsed = handler.parse(self.context.game.release) + if parsed is None: + return None, "not a recognized releases URL", None + return handler.download(parsed, self.context) + + def from_link(self, link): + """This link's result, or None if no handler recognised it.""" + for handler in self.handlers(): + parsed = handler.parse(link) + if parsed is not None: + return handler.download(parsed, self.context) + return None + + def run(self): + """The files downloaded, why none were, and the release info.""" + game = self.context.game + if game.release: + return self.from_release_link() + for link in game.links: + result = self.from_link(link) + if result is not None: + return result + return None, UnusableLinks(self.context.config, game.links).detail(), \ + None diff --git a/src/apworld_tester/pipeline/download/handlers/__init__.py b/src/apworld_tester/pipeline/download/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apworld_tester/pipeline/download/handlers/base.py b/src/apworld_tester/pipeline/download/handlers/base.py new file mode 100644 index 0000000..76fe751 --- /dev/null +++ b/src/apworld_tester/pipeline/download/handlers/base.py @@ -0,0 +1,45 @@ +"""What every link handler shares.""" + +import os + +from apworld_tester.pipeline.download.local_file import LocalFile + + +class LinkHandler: + """One recognised shape of sheet link, and how to fetch from it. + + parse() returning None means "not my shape", which is different from + "mine, and it failed": the caller only moves on to the next link in + the first case. + """ + + def __init__(self, shapes): + self.shapes = shapes + + def parse(self, link): + raise NotImplementedError + + def download(self, parsed, context): + raise NotImplementedError + + def file(self, context, filename): + return LocalFile(context.session, context.target_path(filename)) + + def collect(self, candidates, context, fetch, verify): + """Download each candidate, dropping any that is another game.""" + downloaded, kept, rejected = [], [], [] + for candidate in candidates: + path, status = fetch(candidate) + if status in LocalFile.NEEDS_DOWNLOAD and verify: + mismatch = context.wrong_game(path) + if mismatch is not None: + os.remove(path) + rejected.append((candidate["name"], mismatch)) + continue + downloaded.append((path, status)) + kept.append(candidate) + return downloaded, kept, rejected + + def rejected_detail(self, rejected, subject): + details = "; ".join(f"{name} -> {found}" for name, found in rejected) + return f"downloaded {subject} didn't match this game: {details}" diff --git a/src/apworld_tester/pipeline/download/handlers/gitea_release.py b/src/apworld_tester/pipeline/download/handlers/gitea_release.py new file mode 100644 index 0000000..fb20688 --- /dev/null +++ b/src/apworld_tester/pipeline/download/handlers/gitea_release.py @@ -0,0 +1,44 @@ +"""A releases page on a self-hosted Gitea or Forgejo instance.""" + +from apworld_tester.pipeline.download.handlers.base import LinkHandler +from apworld_tester.pipeline.download.release_downloader import ( + ReleaseDownloader, +) + + +class GiteaRelease(LinkHandler): + """Codeberg and friends. + + The release JSON is close enough to GitHub's that ReleaseDownloader + handles it unchanged, given headers that omit the GitHub token. + """ + + def __init__(self, shapes, previous=None): + super().__init__(shapes) + self.previous = previous + + def parse(self, link): + return self.shapes.gitea_releases(link) + + def latest(self, context, host, owner, repository): + response = context.session.get( + f"https://{host}/api/v1/repos/{owner}/{repository}" + "/releases/latest", + headers=context.json_headers, + ) + if response.status_code == 404: + return None + response.raise_for_status() + return response.json() + + def download(self, parsed, context): + host, owner, repository = parsed + release = self.latest(context, host, owner, repository) + if release is None: + return None, "no release found on this host", None + return ReleaseDownloader( + context=context, + release=release, + headers=context.plain_headers, + previous_published_at=(self.previous or {}).get("published_at"), + ).run() diff --git a/src/apworld_tester/pipeline/download/handlers/github_file.py b/src/apworld_tester/pipeline/download/handlers/github_file.py new file mode 100644 index 0000000..8ebe6ce --- /dev/null +++ b/src/apworld_tester/pipeline/download/handlers/github_file.py @@ -0,0 +1,58 @@ +"""A link straight at an apworld committed in a GitHub repo.""" + +import os +import urllib.parse + +import requests + +from apworld_tester.pipeline.build.archipelago import ( + ArchipelagoBuild, +) +from apworld_tester.pipeline.download.local_file import LocalFile +from apworld_tester.pipeline.download.handlers.base import LinkHandler + + +class GitHubFile(LinkHandler): + """A committed file, named exactly by the sheet. + + No identity check: the link names one file, so there is no second + candidate a name comparison could choose between. + """ + + def parse(self, link): + return self.shapes.github_file(link) + + def download(self, parsed, context): + """Fetch the file as it currently stands on that branch.""" + owner, repository, branch, path = parsed + filename = os.path.basename(urllib.parse.unquote(path)) + local = self.file(context, filename) + url = ("https://raw.githubusercontent.com/" + f"{owner}/{repository}/{branch}/{path}") + status = local.status(local.remote_size(url)) + if status in LocalFile.NEEDS_DOWNLOAD: + local.download(url) + published = self.published_at(context, owner, repository, path, branch) + return [(local.path, status)], None, {"published_at": published} + + def published_at(self, context, owner, repository, path, branch): + """When this file was last committed, or None. + + Best effort: a blob link has no release to read a date from, and + a download should not fail over display information. + """ + try: + response = context.session.get( + f"{ArchipelagoBuild.API_ROOT}/repos/{owner}/{repository}" + "/commits", + params={"path": path, "sha": branch, "per_page": 1}, + ) + response.raise_for_status() + commits = response.json() + except requests.RequestException: + return None + if not commits: + return None + commit = commits[0].get("commit", {}) + return ((commit.get("committer") or {}).get("date") + or (commit.get("author") or {}).get("date")) diff --git a/src/apworld_tester/pipeline/download/handlers/github_release.py b/src/apworld_tester/pipeline/download/handlers/github_release.py new file mode 100644 index 0000000..a22e96b --- /dev/null +++ b/src/apworld_tester/pipeline/download/handlers/github_release.py @@ -0,0 +1,81 @@ +"""A GitHub releases page, the sheet's usual link.""" + +from apworld_tester.pipeline.download.handlers.base import LinkHandler +from apworld_tester.pipeline.download.release_downloader import ( + ReleaseDownloader, +) +from apworld_tester.pipeline.download.release_identity import ( + ReleaseIdentity, +) +from apworld_tester.pipeline.download.release_picker import ReleasePicker +from apworld_tester.pipeline.download.walk_back import WalkBack + + +class GitHubRelease(LinkHandler): + """Resolve which release the link means, then fetch its apworld.""" + + def __init__(self, shapes, previous=None): + super().__init__(shapes) + self.previous = previous + + def parse(self, link): + return self.shapes.releases(link) + + def resolve(self, parsed, context): + """The release this link resolves to, or None. + + A hand-pinned tag prefix wins, then a link naming one exact + tag, then the search term. Runs every pass: it is the one call + that can report a new release, which is the only thing able to + change a previously failed answer. + """ + owner, repository, term, tag = parsed + picker = ReleasePicker(context, owner, repository, term) + overrides = context.config.get("release_overrides") or {} + prefix = overrides.get(context.game.name) + if prefix: + return picker.by_tag_prefix(prefix) + if tag: + return picker.by_tag(tag) + return picker.pick() + + def fetch(self, parsed, context, release): + """This release's apworld, walking back if it ships none. + + Only that one cause is walked back from. "Ships another game's + apworld" must not be, since an older release of a multi-world + repo is precisely the wrong answer there. + """ + owner, repository = parsed[0], parsed[1] + downloader = ReleaseDownloader( + context=context, + release=release, + # What this row's date already says, so a re-upload of + # unchanged bytes keeps it instead of moving it forward. + previous_published_at=(self.previous or {}).get("published_at"), + ) + downloaded, reason, info = downloader.run() + if (downloaded is not None or context.identifier is None + or downloader.cause != ReleaseDownloader.NO_APWORLD): + return downloaded, reason, info + fallback, reason = WalkBack( + context=context, + owner=owner, + repository=repository, + release=release, + ).run(reason) + return fallback if fallback is not None else (None, reason, info) + + def download(self, parsed, context): + release = self.resolve(parsed, context) + if release is None: + return None, "no release matched the search term in the link", None + identity = ReleaseIdentity(release) + repeat = identity.repeated(self.previous) + if repeat is not None: + return None, repeat, identity.failure( + {"published_at": None}, repeat) + downloaded, reason, info = self.fetch(parsed, context, release) + if downloaded is None: + return None, reason, identity.failure(info, reason) + return downloaded, reason, info diff --git a/src/apworld_tester/pipeline/download/handlers/github_repo.py b/src/apworld_tester/pipeline/download/handlers/github_repo.py new file mode 100644 index 0000000..32aef24 --- /dev/null +++ b/src/apworld_tester/pipeline/download/handlers/github_repo.py @@ -0,0 +1,115 @@ +"""A bare repository link, for a world never cut into a release.""" + +import os + +import requests + +from apworld_tester.pipeline.build.archipelago import ( + ArchipelagoBuild, +) +from apworld_tester.pipeline.download.local_file import LocalFile +from apworld_tester.core.model.apworld import ApworldFile +from apworld_tester.pipeline.download.handlers.base import LinkHandler + + +class GitHubRepo(LinkHandler): + """Browses the repo's own file tree for an apworld.""" + + def parse(self, link): + return self.shapes.github_repo(link) + + def resolve_branch(self, context, owner, repository, branch): + """The real default branch, so a version reads as a name. + + "HEAD" works as a ref, but resolving it means the status page + shows a branch rather than the literal string. + """ + if branch != "HEAD": + return branch + try: + response = context.session.get( + f"{ArchipelagoBuild.API_ROOT}/repos/{owner}/{repository}") + response.raise_for_status() + return response.json().get("default_branch") or "HEAD" + except requests.RequestException: + return "HEAD" + + def tree_entries(self, context, owner, repository, branch): + """Every apworld in the tree, with its size. + + One call: the trees API takes recursive=1 itself, and returns + each blob's size for free. + """ + try: + response = context.session.get( + f"{ArchipelagoBuild.API_ROOT}/repos/{owner}/{repository}" + f"/git/trees/{branch}", + params={"recursive": "1"}, + ) + response.raise_for_status() + except requests.RequestException: + return [] + return [ + (entry["path"], entry.get("size")) + for entry in response.json().get("tree", []) + if entry.get("type") == "blob" + and entry["path"].lower().endswith(ApworldFile.SUFFIX) + ] + + def candidates(self, entries): + return [ + {"name": os.path.basename(path), "path": path, "size": size} + for path, size in entries + ] + + def download(self, parsed, context): + owner, repository, branch = parsed + branch = self.resolve_branch(context, owner, repository, branch) + entries = self.tree_entries(context, owner, repository, branch) + if not entries: + return None, ("repo link has no release and no .apworld file " + "found in it"), None + chosen = context.matcher.select(self.candidates(entries)) + downloaded, kept, rejected = self.collect( + candidates=chosen, + context=context, + fetch=lambda candidate: self.fetch( + candidate, context, owner, repository, branch), + verify=len(chosen) > 1, + ) + if not downloaded: + detail = self.rejected_detail(rejected, "file(s) from repo") + return None, detail, None + published = self.published_at( + context, owner, repository, kept[0]["path"], branch) + return downloaded, None, {"published_at": published} + + def fetch(self, candidate, context, owner, repository, branch): + local = self.file(context, candidate["name"]) + status = local.status(candidate.get("size")) + if status in LocalFile.NEEDS_DOWNLOAD: + local.download( + f"https://raw.githubusercontent.com/{owner}/{repository}" + f"/{branch}/{candidate['path']}") + return local.path, status + + def published_at(self, context, owner, repository, path, branch): + """When the first kept file was last committed. + + Only the first: one API call per candidate otherwise. + """ + try: + response = context.session.get( + f"{ArchipelagoBuild.API_ROOT}/repos/{owner}/{repository}" + "/commits", + params={"path": path, "sha": branch, "per_page": 1}, + ) + response.raise_for_status() + commits = response.json() + except requests.RequestException: + return None + if not commits: + return None + commit = commits[0].get("commit", {}) + return ((commit.get("committer") or {}).get("date") + or (commit.get("author") or {}).get("date")) diff --git a/src/apworld_tester/pipeline/download/handlers/gitlab_file.py b/src/apworld_tester/pipeline/download/handlers/gitlab_file.py new file mode 100644 index 0000000..d90b9ba --- /dev/null +++ b/src/apworld_tester/pipeline/download/handlers/gitlab_file.py @@ -0,0 +1,44 @@ +"""A link straight at an apworld committed in a GitLab repo.""" + +import os +import urllib.parse + +import requests + +from apworld_tester.pipeline.download.local_file import LocalFile +from apworld_tester.pipeline.download.handlers.base import LinkHandler + + +class GitLabFile(LinkHandler): + """A committed file on GitLab, named exactly by the sheet.""" + + def parse(self, link): + return self.shapes.gitlab_file(link) + + def download(self, parsed, context): + project_path, branch, path = parsed + filename = os.path.basename(urllib.parse.unquote(path)) + local = self.file(context, filename) + url = f"https://gitlab.com/{project_path}/-/raw/{branch}/{path}" + headers = context.json_headers + status = local.status(local.remote_size(url, headers=headers)) + if status in LocalFile.NEEDS_DOWNLOAD: + local.download(url, headers=headers) + published = self.published_at(context, project_path, path, branch) + return [(local.path, status)], None, {"published_at": published} + + def published_at(self, context, project_path, path, branch): + """When this file was last committed, or None.""" + encoded = urllib.parse.quote(project_path, safe="") + try: + response = context.session.get( + f"https://gitlab.com/api/v4/projects/{encoded}" + "/repository/commits", + params={"path": path, "ref_name": branch, "per_page": 1}, + headers=context.json_headers, + ) + response.raise_for_status() + commits = response.json() + except requests.RequestException: + return None + return commits[0].get("committed_date") if commits else None diff --git a/src/apworld_tester/pipeline/download/handlers/gitlab_release.py b/src/apworld_tester/pipeline/download/handlers/gitlab_release.py new file mode 100644 index 0000000..8306f61 --- /dev/null +++ b/src/apworld_tester/pipeline/download/handlers/gitlab_release.py @@ -0,0 +1,74 @@ +"""A GitLab releases page.""" + +import urllib.parse + +from apworld_tester.core.model.apworld import ApworldFile +from apworld_tester.pipeline.download.handlers.base import LinkHandler +from apworld_tester.pipeline.download.local_file import LocalFile + + +class GitLabRelease(LinkHandler): + """The newest release of a GitLab project. + + GitLab's assets are shaped nothing like GitHub's - a release-level + list of {name, url, direct_asset_url} with no per-asset timestamp - + so they are reshaped here rather than reusing the release path. + """ + + def parse(self, link): + return self.shapes.gitlab_releases(link) + + def latest(self, context, project_path): + """GitLab's own newest-release shortcut, or None.""" + encoded = urllib.parse.quote(project_path, safe="") + response = context.session.get( + f"https://gitlab.com/api/v4/projects/{encoded}" + "/releases/permalink/latest", + headers=context.json_headers, + ) + if response.status_code == 404: + return None + response.raise_for_status() + return response.json() + + def assets(self, release, context): + """The release's apworld links, in GitHub's asset shape.""" + links = (release.get("assets") or {}).get("links", []) + return [ + { + "name": link["name"], + "browser_download_url": (link.get("direct_asset_url") + or link["url"]), + } + for link in links + if (link.get("name") or "").lower().endswith(ApworldFile.SUFFIX) + ] + + def fetch(self, asset, context): + local = self.file(context, asset["name"]) + url = asset["browser_download_url"] + headers = context.plain_headers + status = local.status(local.remote_size(url, headers=headers)) + if status in LocalFile.NEEDS_DOWNLOAD: + local.download(url, headers=headers) + return local.path, status + + def download(self, parsed, context): + release = self.latest(context, parsed) + if release is None: + return None, "no GitLab release found for this project", None + info = {"published_at": (release.get("released_at") + or release.get("created_at"))} + assets = self.assets(release, context) + if not assets: + return None, "latest GitLab release has no .apworld asset", info + chosen = context.matcher.select(assets) + downloaded, _, rejected = self.collect( + candidates=chosen, + context=context, + fetch=lambda asset: self.fetch(asset, context), + verify=len(chosen) > 1, + ) + if not downloaded: + return None, self.rejected_detail(rejected, "asset(s)"), info + return downloaded, None, info diff --git a/src/apworld_tester/pipeline/download/handlers/plain_file.py b/src/apworld_tester/pipeline/download/handlers/plain_file.py new file mode 100644 index 0000000..bfdbcfb --- /dev/null +++ b/src/apworld_tester/pipeline/download/handlers/plain_file.py @@ -0,0 +1,48 @@ +"""A plain URL ending in .apworld, on any host.""" + +import email.utils + +import requests + +from apworld_tester.pipeline.download.local_file import LocalFile +from apworld_tester.pipeline.download.handlers.base import LinkHandler + + +class PlainFile(LinkHandler): + """Any other host. Tried last, so a forge handler always wins.""" + + def parse(self, link): + return self.shapes.plain_file(link) + + def download(self, parsed, context): + url, filename = parsed + local = self.file(context, filename) + size, published = self.remote_facts(context, url) + status = local.status(size) + if status in LocalFile.NEEDS_DOWNLOAD: + local.download(url, headers=context.plain_headers) + return [(local.path, status)], None, {"published_at": published} + + def remote_facts(self, context, url): + """The size and date one HEAD request can offer. + + A plain URL has no version to compare, so size is the whole + staleness check. Neither is required. + """ + try: + response = context.session.head( + url, headers=context.plain_headers, allow_redirects=True) + response.raise_for_status() + except requests.RequestException: + return None, None + length = response.headers.get("Content-Length") + size = int(length) if length and length.isdigit() else None + return size, self.modified_at(response.headers.get("Last-Modified")) + + def modified_at(self, modified): + if not modified: + return None + try: + return email.utils.parsedate_to_datetime(modified).isoformat() + except (TypeError, ValueError): + return None diff --git a/src/apworld_tester/pipeline/download/local_file.py b/src/apworld_tester/pipeline/download/local_file.py new file mode 100644 index 0000000..541cd9b --- /dev/null +++ b/src/apworld_tester/pipeline/download/local_file.py @@ -0,0 +1,83 @@ +"""Deciding whether a local copy is still the current one.""" + +import datetime +import os + +import requests + +from apworld_tester.core.model.instant import Instant + + +class LocalFile: + """One file on disk, against what the remote currently offers. + + "It exists" is not "it is current": an author can replace an + asset's content in place, same filename, same version. Two signals + catch that - the asset's own timestamp, and its size - and both are + needed. A replacement that happens to be the same length is + invisible to size alone, and a remote that reports no length at all + makes size no signal whatsoever. + """ + + #: The statuses that mean the file has to be fetched. The other + #: two - "verified" and "assumed" - mean the copy on disk stands. + NEEDS_DOWNLOAD = ("missing", "stale") + + def __init__(self, session, path): + self.session = session + self.path = path + + def outdated(self, remote_updated_at): + """Whether the remote was touched after this copy arrived. + + mtime is when download() wrote this file, so a remote timestamp + later than it means the copy on disk predates what is being + offered now. Without this, a release that replaced its asset + with one of the same length was never fetched, and the recorded + test result went on describing content that no longer exists - + while the page showed the new release's date beside it. + """ + stamp = Instant.of(remote_updated_at) + if stamp is None: + return False + arrived = datetime.datetime.fromtimestamp( + os.path.getmtime(self.path), datetime.timezone.utc) + return arrived < stamp + + def status(self, remote_size, remote_updated_at=None): + """Whether this file is missing, stale, verified or assumed. + + The timestamp is checked before the size, and before a missing + size falls through to "assumed": it is the stronger signal, and + the two cases it catches are exactly the ones size cannot. + """ + if not os.path.exists(self.path): + return "missing" + if self.outdated(remote_updated_at): + return "stale" + if remote_size is None: + return "assumed" + if os.path.getsize(self.path) == remote_size: + return "verified" + return "stale" + + def remote_size(self, url, headers=None): + """The size a HEAD request reports, or None.""" + try: + response = self.session.head(url, headers=headers, + allow_redirects=True) + response.raise_for_status() + except requests.RequestException: + return None + length = response.headers.get("Content-Length") + return int(length) if length and length.isdigit() else None + + def download(self, url, headers=None): + """Stream a URL into this path, creating its directory.""" + response = self.session.get(url, stream=True, headers=headers) + response.raise_for_status() + os.makedirs(os.path.dirname(self.path), exist_ok=True) + with open(self.path, "wb") as handle: + for chunk in response.iter_content(chunk_size=65536): + handle.write(chunk) + return self.path diff --git a/src/apworld_tester/pipeline/download/release_downloader.py b/src/apworld_tester/pipeline/download/release_downloader.py new file mode 100644 index 0000000..a17cad4 --- /dev/null +++ b/src/apworld_tester/pipeline/download/release_downloader.py @@ -0,0 +1,179 @@ +"""Fetching the apworld a release carries.""" + +import os + +from apworld_tester.core.model.apworld import ApworldFile +from apworld_tester.core.model.fingerprint import Fingerprint +from apworld_tester.pipeline.download.local_file import LocalFile +from apworld_tester.pipeline.download.zip_search import ZipSearch + + +class ReleaseDownloader: + """A GitHub-shaped release, and the apworld it ships. + + Shared with Gitea and Forgejo, whose release JSON is close enough + that the same logic applies unchanged. + """ + + #: Why run() came back empty. The caller decides what to do about + #: it from this, never from the message beside it: a release that + #: ships no apworld can be walked back from, and one that ships + #: another game's must not be. + NO_APWORLD = "no_apworld" + WRONG_GAME = "wrong_game" + + NO_APWORLD_DETAIL = "latest release has no .apworld asset" + + def __init__(self, context, release, headers=None, + require_identity=False, previous_published_at=None): + self.context = context + self.release = release + self.headers = headers + self.require_identity = require_identity + self.previous_published_at = previous_published_at + self.cause = None + + def named_assets(self): + return [ + asset for asset in self.release.get("assets", []) + if asset["name"].lower().endswith(ApworldFile.SUFFIX) + ] + + def missing_detail(self): + detail = self.NO_APWORLD_DETAIL + if any(asset["name"].lower().endswith(ZipSearch.SUFFIX) + for asset in self.release.get("assets", [])): + detail += " (checked inside its .zip asset(s) too, none found)" + return detail + + def assets(self): + """Every apworld this release offers, zips included.""" + found = self.named_assets() + if found: + return found + return ZipSearch(self.context, self.release, self.headers).assets() + + def verifies(self, assets): + """Whether a file has to prove which game it is. + + With one candidate there is nothing to choose between, and the + sheet's link points at this release - so that file is the row's + apworld whatever its World class calls itself. A walk-back + release overrides that, since the link no longer points at it. + """ + return len(assets) > 1 or self.require_identity + + @staticmethod + def touched_at(asset): + """When this asset was last written, as the remote reports it. + + The same value published_at() reports for the row, read here so + the two cannot disagree: a file whose recorded release date is + newer than the copy on disk is a file that was never fetched. + """ + return asset.get("updated_at") or asset.get("created_at") + + def fetch(self, asset): + """The asset's path, how it got there, and whether it changed. + + An asset whose timestamp moved is fetched again - the copy on + disk can no longer be trusted to be current - and only then can + the two be compared. Identical bytes mean the author re-uploaded + the same file, so nothing about this row actually changed. A + file lifted out of a zip is taken as changed: ZipSearch does its + own fetching, and there is no earlier copy here to compare to. + """ + if "extracted_path" in asset: + return asset["extracted_path"], asset["status"], True + local = LocalFile(self.context.session, + self.context.target_path(asset["name"])) + url = asset["browser_download_url"] + status = local.status( + local.remote_size(url, headers=self.headers), + remote_updated_at=self.touched_at(asset), + ) + if status not in LocalFile.NEEDS_DOWNLOAD: + return local.path, status, False + before = (Fingerprint.of_file(local.path) + if os.path.exists(local.path) else None) + local.download(url, headers=self.headers) + return local.path, status, Fingerprint.of_file(local.path) != before + + def rejection(self, path, assets): + """Why this file is not ours, or None to keep it. + + A walk-back file that registered no world at all is kept: + nothing was proven either way, and generation testing reports + the import failure far more usefully than a silent drop. + """ + identity = self.context.identifier.identify(path) + if not identity.get("ran"): + return None + found = identity.get("games") or [] + if self.require_identity and not found and len(assets) == 1: + return None + other = self.context.matcher.identity_claimed( + found, self.context.sheet_names) + matches = bool(found) and self.context.matcher.game.matches( + found[0]) + if other: + return f'{found} - belongs to the "{other}" row' + if self.verifies(assets) and not matches: + return found or identity.get("detail") + return None + + def checks(self, status, assets): + return (status in LocalFile.NEEDS_DOWNLOAD + and self.context.identifier is not None + and (self.verifies(assets) or self.context.sheet_names)) + + def published_at(self, kept): + """When the content this row now holds was published. + + Each asset carries its own timestamp; a release's own stays + fixed at creation even when its files are replaced later, so + the assets' is the one to read. But that timestamp moves for a + re-upload of identical bytes too, and then it dates the upload + rather than the world: the page would show a release newer than + the test beside it while the tested file never changed, and the + Compatibility column would call a world current on the strength + of an upload that added nothing. So only an asset whose content + actually changed sets this date, and otherwise the one already + recorded stands. With nothing recorded there is nothing to + hold, and the asset's own timestamp is the best available. + """ + stamps = [(self.touched_at(asset), changed) + for asset, changed in kept] + stamps = [pair for pair in stamps if pair[0]] + replaced = [stamp for stamp, changed in stamps if changed] + if replaced: + return max(replaced) + if self.previous_published_at: + return self.previous_published_at + return max((stamp for stamp, _ in stamps), default=None) + + def run(self): + """The files downloaded, why none were, and the release info.""" + assets = self.assets() + if not assets: + self.cause = self.NO_APWORLD + return None, self.missing_detail(), {"published_at": None} + assets = self.context.matcher.select(assets) + downloaded, kept, rejected = [], [], [] + for asset in assets: + path, status, changed = self.fetch(asset) + reason = (self.rejection(path, assets) + if self.checks(status, assets) else None) + if reason is not None: + os.remove(path) + rejected.append((asset["name"], reason)) + continue + downloaded.append((path, status)) + kept.append((asset, changed)) + if not downloaded: + self.cause = self.WRONG_GAME + details = "; ".join(f"{name} -> {found}" + for name, found in rejected) + return None, (f"downloaded asset(s) didn't match this game: " + f"{details}"), {"published_at": None} + return downloaded, None, {"published_at": self.published_at(kept)} diff --git a/src/apworld_tester/pipeline/download/release_identity.py b/src/apworld_tester/pipeline/download/release_identity.py new file mode 100644 index 0000000..da3ccba --- /dev/null +++ b/src/apworld_tester/pipeline/download/release_identity.py @@ -0,0 +1,55 @@ +"""Remembering which release a failure was decided on.""" + +from apworld_tester.core.state.keys import StateKeys + + +class ReleaseIdentity: + """What makes a resolved release the same one as last run. + + Its tag and publication date, plus every asset's name and size. The + sizes matter because an author can replace an asset in place + without cutting a new tag. + """ + + def __init__(self, release): + self.release = release + + @property + def value(self): + assets = sorted( + (asset.get("name"), asset.get("size")) + for asset in self.release.get("assets") or [] + ) + return { + "tag": self.release.get("tag_name"), + "published_at": self.release.get("published_at"), + "assets": [{"name": name, "size": size} for name, size in assets], + } + + def repeated(self, previous): + """The recorded reason, if this exact release failed before. + + A release that ships no apworld, or only another row's, fails + the same way every run - and finding that out costs a download + of every zip asset plus a container run to identify what came + back. None of that can reach a different answer while the + release is unchanged. Keyed on the release rather than the + link, because the link is usually /releases/latest and a newly + published release is exactly what would fix a world in this + state. Only ever holds a verdict about content: a network error + escapes as an exception and a container that could not start + keeps the file, so neither is cached here. + """ + if not previous: + return None + if previous.get(StateKeys.FAILED_RELEASE) != self.value: + return None + return previous.get(StateKeys.FAILED_REASON) or None + + def failure(self, info, reason): + """A failure recorded against the release it was decided on.""" + return { + **(info or {}), + StateKeys.FAILED_RELEASE: self.value, + StateKeys.FAILED_REASON: reason, + } diff --git a/src/apworld_tester/pipeline/download/release_picker.py b/src/apworld_tester/pipeline/download/release_picker.py new file mode 100644 index 0000000..cc55451 --- /dev/null +++ b/src/apworld_tester/pipeline/download/release_picker.py @@ -0,0 +1,151 @@ +"""Which release a sheet link means.""" + +import re +import urllib.parse + +from apworld_tester.pipeline.build.archipelago import ( + ArchipelagoBuild, +) +from apworld_tester.core.model.apworld import ApworldFile +from apworld_tester.core.model.name import Name + + +class ReleasePicker: + """One repository's releases, narrowed to this game's. + + A repo dedicated to one world can answer with its latest release. A + repo hosting several has to be matched on the link's search term and + the release's own title, and must never answer with a release that + another sheet row already claims. + """ + + def __init__(self, context, owner, repository, term=None): + self.context = context + self.owner = owner + self.repository = repository + self.term = term + self.matcher = context.matcher + + @property + def repo_url(self): + return (f"{ArchipelagoBuild.API_ROOT}/repos/" + f"{self.owner}/{self.repository}") + + def all(self, max_pages=10): + """Every release, newest first, across every page. + + GitHub pages at 30 by default and several source repos hold + more than that, which would silently hide real releases from + every lookup below. + """ + releases = [] + for page in range(1, max_pages + 1): + response = self.context.session.get( + f"{self.repo_url}/releases", + params={"per_page": 100, "page": page}, + ) + response.raise_for_status() + batch = response.json() + releases.extend(batch) + if len(batch) < 100: + break + return releases + + def by_tag(self, tag): + """The release for exactly this tag, or None.""" + encoded = urllib.parse.quote(tag, safe="") + response = self.context.session.get( + f"{self.repo_url}/releases/tags/{encoded}") + if response.status_code == 404: + return None + response.raise_for_status() + return response.json() + + def by_tag_prefix(self, prefix): + """The newest release whose tag starts with this prefix.""" + for release in self.all(): + if (release.get("tag_name") or "").startswith(prefix): + return release + return None + + def claimed(self, release): + return self.matcher.release_claimed(release, self.context.sheet_names) + + def newest(self): + """GitHub's own newest full release. + + Right whenever it resolves, since it skips in-progress + prereleases. The list's first entry is the fallback for a repo + where every release is flagged prerelease, which /latest + excludes rather than relaxing. + """ + response = self.context.session.get(f"{self.repo_url}/releases/latest") + if response.status_code != 404: + response.raise_for_status() + return response.json() + releases = self.all() + return releases[0] if releases else None + + def titled_for_this_row(self): + for release in self.all(): + if self.matcher.title_slug(release) == self.matcher.game.slug: + return release + return None + + def without_a_term(self): + """The release to use when the link named none. + + "Newest in the repo" is a fine answer for a repo holding one + world and a coin flip for one holding twelve, so a newest that + is titled for somebody else's row gives way to the one titled + for this row. + """ + latest = self.newest() + if latest is None or not self.claimed(latest): + return latest + return self.titled_for_this_row() or latest + + def by_title(self, releases): + """The first release whose title holds every word of the term. + + The sheet's term is built for GitHub's own search box, which + matches per word: requiring the whole phrase rejects real terms + like "Jurassic Park Randomizer (SNES) Jurassic Park", where the + link generator repeats the game name. That looseness is what + lets a term land on a sibling world, so a release titled for + another row is skipped and the search continues. + """ + words = re.findall(r"[a-z0-9]+", self.term.lower()) + if not words: + return None + for release in releases: + title = (f"{release.get('name') or ''} " + f"{release.get('tag_name') or ''}").lower() + if all(word in title for word in words) \ + and not self.claimed(release): + return release + return None + + def by_asset_name(self, releases): + """The release carrying an asset named for this game. + + A fork mirroring many worlds often tags every release by build + date alone, so the game's name appears only in which apworlds + the release bundles. + """ + term = Name(self.term).slug + for release in releases: + for asset in release.get("assets", []): + name = asset["name"] + if not name.lower().endswith(ApworldFile.SUFFIX): + continue + if Name(name.rsplit(".", 1)[0]).slug == term: + return release + return None + + def pick(self): + """The release this link means, or None.""" + if self.term is None: + return self.without_a_term() + releases = self.all() + return self.by_title(releases) or self.by_asset_name(releases) diff --git a/src/apworld_tester/pipeline/download/superseded_assets.py b/src/apworld_tester/pipeline/download/superseded_assets.py new file mode 100644 index 0000000..26a46e5 --- /dev/null +++ b/src/apworld_tester/pipeline/download/superseded_assets.py @@ -0,0 +1,58 @@ +"""Clearing apworlds a game's release no longer offers.""" + +import os + + +class SupersededAssets: + """The files left behind when an author renames their asset. + + A download writes each asset under its own filename, so a release + that renamed one leaves the previous name sitting beside it. Both + are then real files in the game's folder, and everything downstream + keys on the path rather than the game - two work items, two test + records, two rows on the status page, and a promotion that refuses + both because they claim one game name between them. + + Only ever run for a game whose download succeeded: with nothing + fetched there is no current set to compare against, and pruning on + a failed network call would delete the copy that still works. + """ + + #: What this class considers. A game's folder holds nothing else, + #: but naming it here keeps a stray file from being deleted on the + #: strength of living in the wrong directory. + SUFFIX = ".apworld" + + def __init__(self, directory, kept): + self.directory = directory + self.kept = {os.path.abspath(path) for path in kept} + + def stale(self): + """Every apworld in the folder that this download did not write.""" + if not self.kept or not os.path.isdir(self.directory): + return [] + found = [] + for entry in sorted(os.listdir(self.directory)): + if not entry.lower().endswith(self.SUFFIX): + continue + path = os.path.join(self.directory, entry) + if os.path.isfile(path) and os.path.abspath(path) not in self.kept: + found.append(path) + return found + + def prune(self): + """Delete them, returning what went. + + A file that cannot be removed is not worth failing a download + over - it means the next run sees a duplicate, which is what + was already happening. + """ + removed = [] + for path in self.stale(): + try: + os.remove(path) + except OSError as error: + print(f"could not remove superseded {path}: {error}") + continue + removed.append(path) + return removed diff --git a/src/apworld_tester/pipeline/download/unusable_links.py b/src/apworld_tester/pipeline/download/unusable_links.py new file mode 100644 index 0000000..b0b58f7 --- /dev/null +++ b/src/apworld_tester/pipeline/download/unusable_links.py @@ -0,0 +1,56 @@ +"""Why none of a game's links could be downloaded from.""" + +import urllib.parse + + +class UnusableLinks: + """Categorise links no handler recognised. + + Reaching here means every recognised shape was tried against every + link, so the status page can say something concrete instead of a + generic "failed". + """ + + #: Hosts that can never serve a downloadable file, whatever handler + #: is written for them: a Discord link is a channel or message, not + #: an asset. Not configuration - no setting makes Discord fetchable. + UNREACHABLE = { + "discord.com": "Discord link only (channel/message link, not a " + "downloadable file) - can't be fetched automatically", + "discordapp.com": "Discord link only (channel/message link, not a " + "downloadable file) - can't be fetched " + "automatically", + } + + def __init__(self, config, links): + self.config = config + self.links = links + + def domains(self): + """Each link's host, or a reason it has none.""" + found = [] + for link in self.links: + parsed = urllib.parse.urlparse(link) + if parsed.scheme not in ("http", "https"): + protocol = parsed.scheme or "(none)" + return None, f"unsupported link protocol {protocol!r}: {link}" + if not parsed.netloc: + return None, f"not a valid URL: {link}" + netloc = parsed.netloc.lower() + found.append(netloc[4:] if netloc.startswith("www.") else netloc) + return found, None + + def detail(self): + domains, problem = self.domains() + if problem is not None: + return problem + for domain in domains: + if domain in self.UNREACHABLE: + return self.UNREACHABLE[domain] + if "github.com" in domains: + return ("GitHub link found, but not a recognized " + "releases/repo/file URL") + if "gitlab.com" in domains: + return "GitLab link found, but not a recognized releases/blob URL" + hosts = ", ".join(sorted(set(domains))) + return f"no supported download source (custom host: {hosts})" diff --git a/src/apworld_tester/pipeline/download/update_history.py b/src/apworld_tester/pipeline/download/update_history.py new file mode 100644 index 0000000..1ca543b --- /dev/null +++ b/src/apworld_tester/pipeline/download/update_history.py @@ -0,0 +1,168 @@ +"""Every distinct apworld file this pipeline has seen, per game. + +One file per game, written only when that game gains content it has not +seen before - so a run touches the handful of games that actually +changed rather than rewriting one shared blob. Deliberately NOT under +downloads/: that directory is a cache and gets cleared to force a clean +re-download, which is harmless for everything else in it. History is the +one thing that cannot be rebuilt afterwards - which version existed on +which date has no other source once it is gone. +""" + +import os +from datetime import datetime, timezone + +from apworld_tester.core.model.apworld import ApworldFile +from apworld_tester.core.model.fingerprint import Fingerprint +from apworld_tester.core.model.name import Name +from apworld_tester.core.state.keys import StateKeys +from apworld_tester.core.state.store import StateStore + + +class UpdateHistory: + """What each game's apworld was, and when. + + Identity is the file's sha256, so a re-download of unchanged content + adds nothing and recording is safe to repeat within a run. The + game's sheet name is stored inside the file as well as encoded in + its name, because the filename is a folded directory name and a + sheet rename would otherwise leave a file nothing could identify. + """ + + def __init__(self, paths, directory=None): + self.directory = directory or paths.history + + def path_for(self, game_name): + folded = Name(game_name).directory + return os.path.join(self.directory, f"{folded}.json") + + def load(self, game_name): + return StateStore.read( + self.path_for(game_name), + {"game": game_name, "watching_since": None, + "baseline_at": None, "files": []}) + + def save(self, game_name, history): + os.makedirs(self.directory, exist_ok=True) + StateStore(self.path_for(game_name)).save(history) + + #: Everything recorded before this field existed was gathered from + #: whatever the hosts happened to say, at whatever moment the file + #: was first downloaded, and none of it measures how often a world + #: changes under observation. The first pass to touch a game draws + #: a line: that instant is the game's one starting point, and only + #: changes seen after it count. See UpdateCadence, which reads it. + @staticmethod + def watching_since(history, stamp): + """When this pipeline began watching this game. + + The earliest content it has already recorded, so a file written + before this field existed still gets an honest answer rather + than a window that starts the day the field was added. Only a + game with no history at all starts watching now. + """ + seen = [item.get("first_seen_at") for item in history["files"] + if item.get("first_seen_at")] + return min(seen) if seen else stamp + + def entry_for(self, apworld_path, sha256, released_at, source_url, now): + """One file's record. + + `released_at` is the upload date the download stage saw; + first_seen_at is when this pipeline observed it, which is the + only honest timestamp for a file whose host reports no date. + """ + manifest = ApworldFile(apworld_path).manifest or {} + return { + "version": manifest.get("world_version"), + "released_at": released_at, + "first_seen_at": (now or datetime.now(timezone.utc)).isoformat(), + "sha256": sha256, + "filename": os.path.basename(apworld_path), + "source_url": source_url, + } + + def record_file(self, game_name, apworld_path, sha256, released_at, + source_url=None, now=None): + """Add this file to the game's history if its content is new. + + A game whose content has not changed still records that it was + looked at. How often a world updates is only meaningful against + how long it has been watched, and without that a game seen once + and a game seen all year are indistinguishable. + """ + history = self.load(game_name) + now = now or datetime.now(timezone.utc) + opened = not history.get("watching_since") + if opened: + history["watching_since"] = self.watching_since( + history, now.isoformat()) + if not history.get("baseline_at"): + opened = True + history["baseline_at"] = now.isoformat() + if any(item.get("sha256") == sha256 for item in history["files"]): + if opened: + self.save(game_name, history) + return None + entry = self.entry_for(apworld_path, sha256, released_at, + source_url, now) + history["game"] = game_name + history["files"].append(entry) + # Oldest first. released_at is missing often enough that it + # cannot be the only key - first_seen_at always exists and + # breaks those ties in the order the pipeline saw them. + history["files"].sort( + key=lambda item: (item.get("released_at") or "", + item.get("first_seen_at") or "")) + self.save(game_name, history) + return entry + + def files_in(self, game_directory): + return [ + name for name in sorted(os.listdir(game_directory)) + if name.lower().endswith(ApworldFile.SUFFIX) + and os.path.isfile(os.path.join(game_directory, name)) + ] + + def record_all(self, state, root_directory, now=None): + """Record every apworld on disk that is not yet in history. + + Hashes the files directly rather than reading apworld_tests: + that dict only gains a hash once a world has been TESTED, so a + file downloaded this run would not appear until the next one - + and this runs right after downloading, which is the point at + which a new version is known. + """ + releases = state.get(StateKeys.GAME_RELEASES) or {} + # game_releases is keyed by the sheet name while the download + # folder is that name folded - map one to the other rather than + # assuming they match as written. + by_folder = {Name(name).directory: info + for name, info in releases.items()} + added = [] + if not os.path.isdir(root_directory): + return added + for folder in sorted(os.listdir(root_directory)): + game_directory = os.path.join(root_directory, folder) + if not os.path.isdir(game_directory): + continue + added += self.record_folder( + folder, game_directory, by_folder.get(folder) or {}, now) + return added + + def record_folder(self, folder, game_directory, release, now): + """Every new apworld in one game's download folder.""" + added = [] + for filename in self.files_in(game_directory): + path = os.path.join(game_directory, filename) + entry = self.record_file( + game_name=folder, + apworld_path=path, + sha256=Fingerprint.of_file(path), + released_at=release.get("published_at"), + source_url=release.get("url"), + now=now, + ) + if entry: + added.append((folder, entry)) + return added diff --git a/src/apworld_tester/pipeline/download/url_shapes.py b/src/apworld_tester/pipeline/download/url_shapes.py new file mode 100644 index 0000000..76508c4 --- /dev/null +++ b/src/apworld_tester/pipeline/download/url_shapes.py @@ -0,0 +1,98 @@ +"""Recognising the shapes a sheet link can take.""" + +import os +import re +import urllib.parse + +from apworld_tester.core.model.apworld import ApworldFile + + +class UrlShapes: + """The link shapes this pipeline knows how to fetch from.""" + + def __init__(self, config): + self.config = config + + def releases(self, url): + """Owner, repo, search term and tag from a releases link. + + A link naming one exact tag is read as such: without it, a repo + hosting several worlds would fall back to "latest", which drifts + to whichever world was tagged most recently. + """ + match = re.match( + r"^https?://(?:www\.)?github\.com/([^/]+)/([^/]+)/releases", + url or "", re.IGNORECASE) + if match is None: + return None + parsed = urllib.parse.urlparse(url) + terms = urllib.parse.parse_qs(parsed.query).get("q", []) + tag_match = re.match( + r"^/[^/]+/[^/]+/releases/tag/([^/?#]+)", parsed.path, + re.IGNORECASE) + return ( + match.group(1), + match.group(2), + terms[0].strip('"') if terms else None, + urllib.parse.unquote(tag_match.group(1)) if tag_match else None, + ) + + def github_file(self, url): + """Owner, repo, branch and path from a committed-file link.""" + match = re.match( + r"^https?://(?:www\.)?github\.com/([^/]+)/([^/]+)" + r"/(?:blob|raw)/([^/]+)/(.+\.apworld)$", + url or "", re.IGNORECASE) + return match.groups() if match else None + + def github_repo(self, url): + """Owner, repo and branch from a bare repository link. + + Deliberately strict, so it does not swallow issues or wiki + links: those should keep falling through to the generic "not a + releases URL" message rather than triggering a tree search that + could never succeed. + """ + match = re.match( + r"^https?://(?:www\.)?github\.com/([^/]+)/([^/]+?)" + r"(?:/tree/([^/?#]+))?/?$", + url or "", re.IGNORECASE) + if match is None: + return None + owner, repository, branch = match.groups() + return owner, repository, branch or "HEAD" + + def gitlab_file(self, url): + """Project path, branch and file path from a GitLab blob link.""" + match = re.match( + r"^https?://(?:www\.)?gitlab\.com/([^/]+(?:/[^/]+)*?)" + r"/-/blob/([^/]+)/(.+\.apworld)$", + url or "", re.IGNORECASE) + return match.groups() if match else None + + def gitlab_releases(self, url): + """The project path from a GitLab releases link.""" + match = re.match( + r"^https?://(?:www\.)?gitlab\.com/([^/]+(?:/[^/]+)*?)" + r"/-/releases(?:[/?#]|$)", + url or "", re.IGNORECASE) + return match.group(1) if match else None + + def gitea_releases(self, url): + """Host, owner and repo from a Gitea-style releases link.""" + match = re.match( + r"^https?://(?!(?:www\.)?(?:github|gitlab)\.com)" + r"([^/]+)/([^/]+)/([^/]+)/releases(?:[/?#]|$)", + url or "", re.IGNORECASE) + return match.groups() if match else None + + def plain_file(self, url): + """A URL ending in .apworld on any host, and its filename.""" + parsed = urllib.parse.urlparse(url or "") + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return None + path = urllib.parse.unquote(parsed.path) + if not path.lower().endswith(ApworldFile.SUFFIX): + return None + filename = os.path.basename(path) + return (url, filename) if filename != ApworldFile.SUFFIX else None diff --git a/src/apworld_tester/pipeline/download/walk_back.py b/src/apworld_tester/pipeline/download/walk_back.py new file mode 100644 index 0000000..3e700fd --- /dev/null +++ b/src/apworld_tester/pipeline/download/walk_back.py @@ -0,0 +1,77 @@ +"""Falling back to an older release that still ships an apworld.""" + +import requests + +from apworld_tester.core.model.apworld import ApworldFile +from apworld_tester.pipeline.download.release_downloader import ( + ReleaseDownloader, +) +from apworld_tester.pipeline.download.release_picker import ReleasePicker + + +class WalkBack: + """One narrow rescue: the newest release forgot the apworld. + + An author cuts a release and re-attaches only a mod zip, leaving + the world perfectly downloadable one release back. Every other + failure is left alone on purpose - in particular "did not match + this game", where an older release holding a different game's + apworld is precisely the wrong answer. + """ + + def __init__(self, context, owner, repository, release): + self.context = context + self.owner = owner + self.repository = repository + self.release = release + + def candidate(self, release): + """Whether this is an older release that ships an apworld. + + Matches on asset filename alone: this runs over a repo's whole + history, and opening every zip in it would cost far more than + the problem is worth. + """ + cutoff = self.release.get("published_at") + published_at = release.get("published_at") + return bool( + not release.get("draft") + and release.get("tag_name") != self.release.get("tag_name") + and published_at + and (not cutoff or published_at < cutoff) + and any(asset["name"].lower().endswith(ApworldFile.SUFFIX) + for asset in release.get("assets", [])) + ) + + def older(self): + """The newest earlier release with an apworld, by date. + + Ordering comes from published_at rather than the order GitHub + returns, which follows the underlying tag and is not the same + thing. + """ + picker = ReleasePicker(self.context, self.owner, self.repository) + try: + releases = picker.all() + except requests.RequestException: + return None + candidates = [r for r in releases if self.candidate(r)] + if not candidates: + return None + return max(candidates, key=lambda release: release["published_at"]) + + def run(self, reason): + """The fallback's result, or None and a combined reason.""" + older = self.older() + if older is None: + return None, reason + fallback = ReleaseDownloader( + context=self.context, + release=older, + require_identity=True, + ).run() + if fallback[0] is not None: + fallback[2]["fallback_release_tag"] = older.get("tag_name") + return fallback, None + return None, (f"{reason}; older release {older.get('tag_name')!r} " + f"was tried too: {fallback[1]}") diff --git a/src/apworld_tester/pipeline/download/zip_search.py b/src/apworld_tester/pipeline/download/zip_search.py new file mode 100644 index 0000000..fcfa425 --- /dev/null +++ b/src/apworld_tester/pipeline/download/zip_search.py @@ -0,0 +1,96 @@ +"""Looking for an apworld inside a release's zip assets.""" + +import os +import tempfile +import zipfile + +import requests + +from apworld_tester.core.model.apworld import ApworldFile +from apworld_tester.pipeline.download.local_file import LocalFile + + +class ZipSearch: + """For a release that publishes a zip instead of an apworld. + + Some repos attach a source snapshot with the built apworld nested + inside. Every zip is re-downloaded to scratch space each time, since + the member's own filename - what a normal download caches on - is + not known until the zip is opened. + """ + + #: The extension a release's archive asset is recognised by. + SUFFIX = ".zip" + + def __init__(self, context, release, headers=None): + self.context = context + self.release = release + self.headers = headers + + def zip_assets(self): + return [ + asset for asset in self.release.get("assets", []) + if asset["name"].lower().endswith(ZipSearch.SUFFIX) + ] + + def members(self, zip_path): + """Every apworld inside the zip, with its size.""" + try: + with zipfile.ZipFile(zip_path) as archive: + return [ + (info.filename, info.file_size) + for info in archive.infolist() + if not info.filename.endswith("/") + and info.filename.lower().endswith(ApworldFile.SUFFIX) + ] + except zipfile.BadZipFile: + return [] + + def extract(self, zip_path, member): + """Unpack one member into the game's folder.""" + target = self.context.target_path(os.path.basename(member)) + os.makedirs(os.path.dirname(target), exist_ok=True) + with zipfile.ZipFile(zip_path) as archive: + with archive.open(member) as source: + with open(target, "wb") as handle: + handle.write(source.read()) + return target + + def unpack(self, zip_path, members, zip_asset): + """The members as assets, already on disk.""" + results = [] + for member, size in members: + target = self.context.target_path(os.path.basename(member)) + status = LocalFile(self.context.session, target).status(size) + if status in LocalFile.NEEDS_DOWNLOAD: + self.extract(zip_path, member) + results.append({ + "name": os.path.basename(member), + "updated_at": zip_asset.get("updated_at"), + "created_at": zip_asset.get("created_at"), + "extracted_path": target, + "status": status, + }) + return results + + def search_one(self, zip_asset): + with tempfile.TemporaryDirectory() as scratch: + zip_path = os.path.join(scratch, zip_asset["name"]) + local = LocalFile(self.context.session, zip_path) + try: + local.download(zip_asset["browser_download_url"], + headers=self.headers) + except requests.RequestException: + return [] + members = self.members(zip_path) + if not members: + return [] + return self.unpack(zip_path, members, zip_asset) + + def assets(self): + """The first zip that holds an apworld wins.""" + for zip_asset in self.zip_assets(): + found = self.search_one(zip_asset) + if found: + return found + return [] diff --git a/src/apworld_tester/pipeline/generation/__init__.py b/src/apworld_tester/pipeline/generation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apworld_tester/pipeline/generation/command.py b/src/apworld_tester/pipeline/generation/command.py new file mode 100644 index 0000000..475890e --- /dev/null +++ b/src/apworld_tester/pipeline/generation/command.py @@ -0,0 +1,99 @@ +"""Building the docker invocations the drivers run in.""" + +import os + +from apworld_tester.core.model.apworld import ApworldFile + + +class ContainerCommand: + """The `docker run` for one driver, and the mounts it needs. + + The paths below are where this pipeline mounts things inside its + own container. Both ends of that contract live in this repository - + these mount arguments and the drivers that receive them - so they + are part of the code rather than something to configure. + """ + + #: Where each driver script is mounted. + DRIVER_PATH = "/app/run_test.py" + IDENTIFY_DRIVER_PATH = "/app/identify_apworld.py" + VERIFY_DRIVER_PATH = "/app/verify_companions.py" + + #: Where the apworld under test, the seeds and the ROMs go. + APWORLD_DIRECTORY = "/app/custom_worlds" + OUTPUT_DIRECTORY = "/data/output" + DATA_DIRECTORY = "/app/data" + + #: A writable cache directory. The image runs as uid 1000 but + #: leaves HOME as "/", which is root-owned, so anything resolving + #: "~/.cache" fails with PermissionError before generation starts. + CACHE_ENVIRONMENT = {"HOME": "/tmp", "XDG_CACHE_HOME": "/tmp/.cache"} + + def __init__(self, config, paths, image): + self.config = config + self.paths = paths + self.image = image + + @property + def prefix(self): + """The part of every invocation that never varies.""" + return ["docker", "run", "--rm", "--network", "none", + "--entrypoint", "python", *self.cache_environment] + + @property + def cache_environment(self): + args = [] + for name, value in self.CACHE_ENVIRONMENT.items(): + args += ["-e", f"{name}={value}"] + return args + + @property + def common_client_mount(self): + """CommonClient.py, mounted back in from the checkout. + + The image excludes every client file, correctly - but a few + apworlds import their own during generation and fail with "No + module named 'CommonClient'" purely because it is absent. + Absent source means no mount: it exists only after a clone. + """ + if not os.path.isfile(self.paths.common_client): + return [] + return ["-v", f"{self.paths.common_client}:/app/CommonClient.py:ro"] + + @property + def rom_mounts(self): + """Every base ROM, mounted individually. + + Per file rather than mounting the directory over /app/data, + which would shadow the image's own shipped data. Worlds declare + rom_file inconsistently - some embed "data/", some do not - so + each is mounted at both locations. + """ + directory = self.paths.roms + if not os.path.isdir(directory): + return [] + data_in = self.DATA_DIRECTORY + args = [] + for name in sorted(os.listdir(directory)): + path = os.path.join(directory, name) + if os.path.isfile(path): + args += ["-v", f"{path}:{data_in}/{name}:ro"] + args += ["-v", f"{path}:/app/{name}:ro"] + return args + + def apworld_mount(self, apworld_path): + """The mount and container path for the world under test.""" + if not apworld_path: + return [], None + name = ApworldFile(apworld_path).importable_name + inside = f"{self.APWORLD_DIRECTORY}/{name}" + return ["-v", f"{apworld_path}:{inside}:ro"], inside + + def driver_mounts(self, output_dir): + """The driver, the output directory, the ROMs and the client.""" + return [ + "-v", f"{self.paths.driver}:{self.DRIVER_PATH}:ro", + "-v", f"{output_dir}:{self.OUTPUT_DIRECTORY}", + *self.rom_mounts, + *self.common_client_mount, + ] diff --git a/src/apworld_tester/pipeline/generation/companion_check.py b/src/apworld_tester/pipeline/generation/companion_check.py new file mode 100644 index 0000000..473678d --- /dev/null +++ b/src/apworld_tester/pipeline/generation/companion_check.py @@ -0,0 +1,83 @@ +"""Which core worlds can actually generate in this container.""" + +import os +import subprocess + +from apworld_tester.core.config.run_settings import RunSettings +from apworld_tester.pipeline.generation.command import ContainerCommand +from apworld_tester.pipeline.generation.report import DriverReport + + +class CompanionCheck: + """Tries every core world once, so the rest can trust the pool. + + Several core worlds need a base ROM this container has no copy of, + and drawing one fails the seed - with the failure landing on the + apworld under test rather than on the companion that caused it. + """ + + #: The whole verification's ceiling, and each world's share of it. + #: Not configuration: the per-world limit is deliberately shorter + #: than a generation timeout, because a companion slow to generate + #: would multiply that cost across every seed that draws it. + TIMEOUT = 1800 + PER_WORLD_TIMEOUT = 120 + + def __init__(self, config, paths, image): + self.config = config + self.paths = paths + self.image = image + self.command = ContainerCommand(config, paths, image) + + def build(self, output_dir, per_world_timeout, spoiler): + verify_in = ContainerCommand.VERIFY_DRIVER_PATH + return [ + *self.command.prefix, + "-v", f"{self.paths.verify_driver}:{verify_in}:ro", + "-v", (f"{self.paths.driver}:" + f"{ContainerCommand.DRIVER_PATH}:ro"), + "-v", (f"{output_dir}:" + f"{ContainerCommand.OUTPUT_DIRECTORY}"), + *self.command.rom_mounts, + *self.command.common_client_mount, + self.image, + verify_in, + "--output-dir", ContainerCommand.OUTPUT_DIRECTORY, + "--timeout", str(per_world_timeout), + "--spoiler", str(spoiler), + ] + + @staticmethod + def not_verified(detail): + return {"ran": False, "verified": [], "rejected": {}, + "detail": detail} + + def parse(self, result): + data = DriverReport(result).data + if data is not None: + data.setdefault("ran", True) + return data + return self.not_verified( + f"no JSON output (exit {result.returncode}): " + f"{DriverReport.tail(result.stderr)}") + + def run(self, output_dir, timeout=None, per_world_timeout=None, + spoiler=None): + """The pool of usable companions, or why there is none.""" + timeout = timeout or self.TIMEOUT + per_world_timeout = per_world_timeout or self.PER_WORLD_TIMEOUT + if spoiler is None: + spoiler = RunSettings(self.config).spoiler + output_dir = os.path.abspath(output_dir) + os.makedirs(output_dir, exist_ok=True) + try: + result = subprocess.run( + self.build(output_dir, per_world_timeout, spoiler), + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return self.not_verified("companion verification did not " + f"finish within {timeout}s") + return self.parse(result) diff --git a/src/apworld_tester/pipeline/generation/identifier.py b/src/apworld_tester/pipeline/generation/identifier.py new file mode 100644 index 0000000..adee4c7 --- /dev/null +++ b/src/apworld_tester/pipeline/generation/identifier.py @@ -0,0 +1,75 @@ +"""Asking an apworld which game it registers as.""" + +import os +import subprocess +import tempfile + +from apworld_tester.core.model.apworld import ApworldFile +from apworld_tester.pipeline.generation.command import ContainerCommand +from apworld_tester.pipeline.generation.report import DriverReport + + +class ApworldIdentifier: + """What a file's own World class says it is. + + Lighter than a test: no ROMs, no output volume, no generation. Used + right after download to check the content matches the row that + fetched it, before the file is trusted at all. + """ + + #: Seconds an import check may take. Not configuration: this only + #: loads the apworld and reads which games it registers, so a file + #: still going after a minute is stuck, not slow. + TIMEOUT = 60 + + def __init__(self, config, paths, image): + self.paths = paths + self.image = image + self.command = ContainerCommand(config, paths, image) + + def build(self, apworld_path): + mount, inside = self.command.apworld_mount(apworld_path) + identify_in = ContainerCommand.IDENTIFY_DRIVER_PATH + return [ + *self.command.prefix, + *mount, + "-v", f"{self.paths.identify_driver}:{identify_in}:ro", + *self.command.common_client_mount, + self.image, + identify_in, + "--apworld", inside, + ] + + def failed(self, detail): + return {"ran": False, "games": [], "detail": detail} + + def identify(self, apworld_path, timeout=None): + """Which games this file registers, and whether the check ran.""" + timeout = timeout or self.TIMEOUT + apworld_path = os.path.abspath(apworld_path) + if not os.path.isfile(apworld_path): + return self.failed(f"{apworld_path} does not exist (not a file)") + try: + result = self.run(apworld_path, timeout) + except subprocess.TimeoutExpired: + return self.failed( + f"identify check did not finish within {timeout}s") + data = DriverReport(result).data + if data is not None: + data.setdefault("ran", True) + return data + return self.failed( + f"no JSON output (exit {result.returncode}): " + f"{DriverReport.tail(result.stderr)}") + + def run(self, apworld_path, timeout): + """Run the driver against a prepared copy of the file. + + Repaired here as well as at test time, so a backslash-separated + world is not rejected at download for registering no game and + never reaching the test that could repair it. + """ + with tempfile.TemporaryDirectory(prefix="apworld-id-") as workdir: + checked, _ = ApworldFile(apworld_path).prepare(workdir) + return subprocess.run(self.build(checked), capture_output=True, + text=True, timeout=timeout) diff --git a/src/apworld_tester/pipeline/generation/report.py b/src/apworld_tester/pipeline/generation/report.py new file mode 100644 index 0000000..3b13c41 --- /dev/null +++ b/src/apworld_tester/pipeline/generation/report.py @@ -0,0 +1,54 @@ +"""Reading what a driver reported.""" + +import json + + +class DriverReport: + """The JSON one driver run wrote to stdout.""" + + def __init__(self, result): + self.result = result + + @staticmethod + def tail(text, lines=20): + return "\n".join(text.splitlines()[-lines:]) + + @staticmethod + def last_json_object(text): + """The driver's report, however it ended up on the line. + + Not "the last line starting with {": worlds write to raw stdout + during generation, and one that writes without a trailing + newline leaves its output glued to the front of the report - + Super Metroid's randomizer emits a bare "*" per retry, so the + report arrives as "*{...}" and would read as no report at all. + """ + for line in reversed(text.splitlines()): + line = line.strip() + start = line.find("{") + while start != -1: + try: + return json.loads(line[start:]) + except json.JSONDecodeError: + start = line.find("{", start + 1) + return None + + @property + def data(self): + return self.last_json_object(self.result.stdout) + + def parsed(self): + """The report, or a failure built from what it printed.""" + report = self.data + if report is not None: + report.setdefault("stderr_tail", self.tail(self.result.stderr)) + return report + return { + "game": None, + "outcome": "failed", + "detail": ("no JSON report on stdout " + f"(exit code {self.result.returncode})"), + "elapsed_seconds": None, + "stdout_tail": self.tail(self.result.stdout), + "stderr_tail": self.tail(self.result.stderr), + } diff --git a/src/apworld_tester/pipeline/generation/tester.py b/src/apworld_tester/pipeline/generation/tester.py new file mode 100644 index 0000000..09e14b5 --- /dev/null +++ b/src/apworld_tester/pipeline/generation/tester.py @@ -0,0 +1,136 @@ +"""Testing one world by generating seeds for it.""" + +import os +import subprocess +import tempfile + +from apworld_tester.core.model.apworld import ApworldFile +from apworld_tester.pipeline.generation.command import ContainerCommand +from apworld_tester.pipeline.generation.report import DriverReport + + +class WorldTester: + """Runs every generation mode against one world.""" + + #: Startup slack added to the container's own ceiling, on top of + #: every mode's generation timeout. Not configuration: it covers + #: docker start and interpreter boot, which no run changes. + HOST_TIMEOUT_BUFFER = 30 + + def __init__(self, config, paths, image): + self.config = config + self.paths = paths + self.image = image + self.command = ContainerCommand(config, paths, image) + + def settings(self, key, default): + return self.config.value("testing", key, default) + + def build(self, apworld_path, output_dir, timeout, companion_range, + spoiler, repeats, companion_pool, game, random_repeats): + """The full `docker run` for one world's whole test.""" + mount, inside = self.command.apworld_mount(apworld_path) + selector = ["--apworld", inside] if inside else ["--game", game] + return [ + *self.command.prefix, + *mount, + *self.command.driver_mounts(output_dir), + self.image, + ContainerCommand.DRIVER_PATH, + *selector, + "--output-dir", ContainerCommand.OUTPUT_DIRECTORY, + "--timeout", str(timeout), + "--companion-min", str(companion_range[0]), + "--companion-max", str(companion_range[1]), + "--spoiler", str(spoiler), + "--repeats", str(repeats), + "--random-repeats", str(random_repeats), + "--companion-pool", ",".join(companion_pool or ()), + ] + + def host_timeout(self, timeout, repeats, random_repeats): + """How long to let the whole container run. + + Every mode runs in the one container, each bounded by its own + timeout, so the host allows for all of them plus startup slack. + A ceiling rather than an expected cost. + """ + generations = 2 * repeats + 1 + 2 * random_repeats + return timeout * generations + self.HOST_TIMEOUT_BUFFER + + def missing_file(self, path): + """The failure for a path that is not a file. + + Docker silently creates an empty directory at a missing mount + source rather than failing, which turns a not-yet-written + apworld into a bogus directory that breaks the content hash + days later with a confusing "Is a directory". + """ + return { + "game": None, + "outcome": "failed", + "detail": f"{path} does not exist (not a file)", + "elapsed_seconds": None, + } + + def test(self, apworld_path=None, game=None, output_dir=None, + timeout=None, companion_range=(2, 5), spoiler=3, repeats=10, + companion_pool=(), random_repeats=3): + """Run every mode against one world, core or downloaded.""" + output_dir = os.path.abspath(output_dir or self.paths.output) + os.makedirs(output_dir, exist_ok=True) + timeout = timeout or self.settings("generation_timeout", 300) + if apworld_path is not None: + apworld_path = os.path.abspath(apworld_path) + if not os.path.isfile(apworld_path): + return self.missing_file(apworld_path) + with tempfile.TemporaryDirectory(prefix="apworld-") as workdir: + tested, repaired = self.prepared(apworld_path, workdir) + report = self.run_once( + apworld_path=tested, + game=game, + output_dir=output_dir, + timeout=timeout, + companion_range=companion_range, + spoiler=spoiler, + repeats=repeats, + companion_pool=companion_pool, + random_repeats=random_repeats, + ) + if repaired: + report["repaired"] = True + return report + + def prepared(self, apworld_path, workdir): + """The path to test, and whether it had to be repaired.""" + if not apworld_path: + return None, False + return ApworldFile(apworld_path).prepare(workdir) + + def run_once(self, apworld_path, game, output_dir, timeout, + companion_range, spoiler, repeats, companion_pool, + random_repeats): + """One container run, and the report it produced.""" + limit = self.host_timeout(timeout, repeats, random_repeats) + command = self.build( + apworld_path=apworld_path, + output_dir=output_dir, + timeout=timeout, + companion_range=companion_range, + spoiler=spoiler, + repeats=repeats, + companion_pool=companion_pool, + game=game, + random_repeats=random_repeats, + ) + try: + result = subprocess.run(command, capture_output=True, text=True, + timeout=limit) + except subprocess.TimeoutExpired: + return { + "game": None, + "outcome": "failed", + "detail": f"docker run did not exit within {limit}s", + "elapsed_seconds": limit, + } + return DriverReport(result).parsed() diff --git a/src/apworld_tester/pipeline/sheet/__init__.py b/src/apworld_tester/pipeline/sheet/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apworld_tester/pipeline/sheet/fetcher.py b/src/apworld_tester/pipeline/sheet/fetcher.py new file mode 100644 index 0000000..037b006 --- /dev/null +++ b/src/apworld_tester/pipeline/sheet/fetcher.py @@ -0,0 +1,62 @@ +"""Downloading the worlds sheet.""" + +import io +import os +import zipfile + +import requests + + +class SheetFetcher: + """The Playable Worlds sheet, as HTML.""" + + def __init__(self, config, paths): + self.config = config + self.paths = paths + + @property + def spreadsheet_id(self): + return self.config.value("sheet", "spreadsheet_id") + + @property + def tab_name(self): + return self.config.value("sheet", "tab_name") + + @property + def export_url(self): + return ("https://docs.google.com/spreadsheets/d/" + f"{self.spreadsheet_id}/export?format=zip") + + def fetch(self): + """The tab's HTML, as bytes. + + Google answers a private sheet with its sign-in page and status + 200 rather than an error, so the content type is checked instead + of the status. + """ + response = requests.get(self.export_url, timeout=120) + response.raise_for_status() + if "application/zip" not in response.headers.get("Content-Type", ""): + raise RuntimeError( + "The server did not return a zip archive. The sheet is " + "most likely not shared with 'anyone with the link'.") + with zipfile.ZipFile(io.BytesIO(response.content)) as archive: + wanted = f"{self.tab_name}.html" + if wanted not in archive.namelist(): + raise RuntimeError( + f"The export has no tab called '{self.tab_name}'. " + f"Available: {', '.join(archive.namelist())}") + return archive.read(wanted) + + def save(self, content=None): + """Write the tab's HTML, returning where it landed.""" + content = self.fetch() if content is None else content + with open(self.paths.sheet_html, "wb") as handle: + handle.write(content) + return self.paths.sheet_html + + def ensure(self): + """Download the sheet only when this machine has no copy.""" + if not os.path.exists(self.paths.sheet_html): + self.save() + return self.paths.sheet_html diff --git a/src/apworld_tester/pipeline/sheet/reader.py b/src/apworld_tester/pipeline/sheet/reader.py new file mode 100644 index 0000000..6a6a64d --- /dev/null +++ b/src/apworld_tester/pipeline/sheet/reader.py @@ -0,0 +1,98 @@ +"""Reading the worlds sheet into Game objects.""" + +from bs4 import BeautifulSoup + +from apworld_tester.core.model.game import Game + + +class SheetReader: + """The sheet's HTML export, as rows.""" + + def __init__(self, config, path): + self.config = config + self.path = path + + def column(self, name): + return (self.config.value("sheet", "columns") or {})[name] + + def rows(self): + """Every table row, as lists of cells. + + Google repeats each row's frozen-column cells in a spacer, which + would shift every column index by one, so those are dropped. + """ + with open(self.path, "r", encoding="utf-8") as handle: + soup = BeautifulSoup(handle.read(), "html.parser") + found = [] + for row in soup.find_all("tr"): + cells = [ + cell for cell in row.find_all("td") + if "freezebar-cell" not in cell.get("class", []) + ] + if cells: + found.append(cells) + return found + + def header(self, rows): + """Where the header row is, and the headings it holds.""" + for index, cells in enumerate(rows): + texts = [cell.get_text(strip=True) for cell in cells] + if self.column("name") in texts: + return index, texts + raise ValueError( + f"No '{self.column('name')}' header found in the export.") + + def indexes(self, headers): + """Where each field sits in a row.""" + if self.column("link") not in headers: + raise ValueError(f"No '{self.column('link')}' column found.") + optional = {} + for field in ("stability", "pr_status"): + heading = self.column(field) + optional[field] = (headers.index(heading) + if heading in headers else None) + return (headers.index(self.column("name")), + headers.index(self.column("link")), + optional["stability"], + optional["pr_status"]) + + @staticmethod + def text_of(cells, index): + """One cell's text, or None when missing or empty.""" + if index is None or index >= len(cells): + return None + return cells[index].get_text(strip=True) or None + + @staticmethod + def links_in(cell): + """Every hyperlink target in one cell, in the order written.""" + return [anchor.get("href") for anchor in cell.find_all("a") + if anchor.get("href")] + + def build(self, cells, indexes): + """One row as a Game, or None when it has no name.""" + name_index, link_index, stability_index, status_index = indexes + name = self.text_of(cells, name_index) + if name is None: + return None + game = Game( + name=name, + stability=self.text_of(cells, stability_index), + pr_status=self.text_of(cells, status_index), + ) + if link_index < len(cells): + for link in self.links_in(cells[link_index]): + game.add_link(link) + return game + + def games(self): + """Every world listed in the export.""" + rows = self.rows() + header_index, headers = self.header(rows) + indexes = self.indexes(headers) + found = [] + for cells in rows[header_index + 1:]: + game = self.build(cells, indexes) + if game is not None: + found.append(game) + return found diff --git a/src/apworld_tester/pipeline/update/__init__.py b/src/apworld_tester/pipeline/update/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apworld_tester/pipeline/update/download_reporter.py b/src/apworld_tester/pipeline/update/download_reporter.py new file mode 100644 index 0000000..db9b7e7 --- /dev/null +++ b/src/apworld_tester/pipeline/update/download_reporter.py @@ -0,0 +1,37 @@ +"""The download stage's progress line.""" + +import os + +from apworld_tester.core.display.progress import Progress + + +class DownloadReporter: + """One line per game, saying what its apworld did. + + The download run calls back serialized, so this needs no lock of its + own. What each game's last asset did is remembered so the line can + still name it once the game itself finishes. + """ + + #: What each download status means, in words. + STATUS_LABELS = { + "missing": "downloaded (new)", + "stale": "downloaded (updated)", + "verified": "already present (verified up to date)", + "assumed": "already present (not verified)", + } + + def __init__(self, config, total): + self.bar = Progress(total, config=config, + labels={"ok": "ok", "none": "none"}) + self.last_asset = {} + + def on_asset(self, game_name, path, status): + label = self.STATUS_LABELS.get(status, status) + self.last_asset[game_name] = f"{label} {os.path.basename(path)}" + + def on_game(self, index, total, game_name, downloaded): + detail = self.last_asset.pop(game_name, "no apworld") + self.bar.update( + index, "ok" if downloaded else "none", game_name, + fallback_line=f"[{index}/{total}] {game_name}: {detail}") diff --git a/src/apworld_tester/pipeline/update/stage_error.py b/src/apworld_tester/pipeline/update/stage_error.py new file mode 100644 index 0000000..8749153 --- /dev/null +++ b/src/apworld_tester/pipeline/update/stage_error.py @@ -0,0 +1,15 @@ +"""A failure, named by the stage it happened in.""" + + +class StageError(RuntimeError): + """Which stage failed, so the run can say so and stop. + + Raised at the stage boundary rather than inside it: a stage can fail + anywhere after it was opened, and an unnamed traceback makes a long + run's failure much harder to place. + """ + + def __init__(self, stage, original): + super().__init__(f"failed during '{stage}': {original}") + self.stage = stage + self.original = original diff --git a/src/apworld_tester/pipeline/update/update_run.py b/src/apworld_tester/pipeline/update/update_run.py new file mode 100644 index 0000000..a8a68bd --- /dev/null +++ b/src/apworld_tester/pipeline/update/update_run.py @@ -0,0 +1,277 @@ +"""Everything the testing half does in one pass. + +Update Archipelago to the newest release, refresh the worlds sheet, +download whatever changed, and test each world by generating seeds. +What happens to the results afterwards - promoting, publishing, +deploying - is the other half's job and is not known about here. +""" + +import contextlib + +import requests + +from apworld_tester.core.config.config import Config +from apworld_tester.core.config.paths import Paths +from apworld_tester.core.config.run_settings import RunSettings +from apworld_tester.core.config.secrets import Secrets +from apworld_tester.core.model.name import Name +from apworld_tester.core.state.keys import StateKeys +from apworld_tester.core.state.store import StateStore +from apworld_tester.pipeline.batch.batch import Batch +from apworld_tester.pipeline.batch.discontinued import Discontinued +from apworld_tester.pipeline.batch.orphaned_records import ( + OrphanedRecords, +) +from apworld_tester.pipeline.batch.download_failures import ( + DownloadFailures, +) +from apworld_tester.pipeline.batch.reporter import Reporter +from apworld_tester.pipeline.batch.results import Results +from apworld_tester.pipeline.build.archipelago import ArchipelagoBuild +from apworld_tester.pipeline.download.download_run import DownloadRun +from apworld_tester.pipeline.download.update_history import ( + UpdateHistory, +) +from apworld_tester.pipeline.generation.identifier import ApworldIdentifier +from apworld_tester.pipeline.sheet.fetcher import SheetFetcher +from apworld_tester.pipeline.sheet.reader import SheetReader +from apworld_tester.pipeline.update.download_reporter import ( + DownloadReporter, +) +from apworld_tester.pipeline.update.stage_error import StageError + + +class UpdateRun: + """The four stages, in the order they run. + + Takes no lock of its own: a caller that also publishes the results + holds one for the whole thing, so nothing swaps underneath a + generation still in progress. + """ + + def __init__(self, config=None, paths=None, stability=None, fetch=None, + jobs=None, force_retest=False): + config = config or Config.load() + paths = paths or Paths(config) + self.config = config + self.paths = paths + self.settings = RunSettings(config) + self.store = StateStore(paths.state) + self.build = ArchipelagoBuild(config, paths) + self.stability = stability + self.fetch = fetch + self.jobs = jobs or self.settings.jobs + self.force_retest = force_retest + self.report = {} + + @contextlib.contextmanager + def stage(self, name): + print(f"\n=== {name} ===") + try: + yield + except Exception as error: + raise StageError(name, error) from error + + def snapshot_previous_version(self, previous_tag): + """Keep last version's results before this one overwrites them. + + The only source for "did this world's outcome change at the + exact moment Archipelago upgraded": it is the state at the + instant the version changed, which no later re-test can + reconstruct. + """ + state = self.store.load() + state[StateKeys.UPGRADE_SNAPSHOT] = dict( + state.get(StateKeys.APWORLD_TESTS, {})) + state[StateKeys.PREVIOUS_ARCHIPELAGO_TAG] = previous_tag + self.store.save(state) + + def update_archipelago(self): + """Put this machine on the newest release and build its image.""" + with self.stage("archipelago_build"): + tag, previous, changed, built = self.build.update_and_build( + self.store) + self.build.ensure_test_image(tag) + self.report.update({ + "archipelago_tag": tag, + "archipelago_previous_tag": previous, + "archipelago_tag_changed": changed, + "archipelago_image_built": built, + }) + print(f"tag {tag}" + (f" (was {previous})" if previous + else " (first run)")) + if changed and previous: + self.snapshot_previous_version(previous) + return tag, changed + + def announce_retired(self, retired): + gone = sorted(key for key, value in retired.items() if value) + back = sorted(key for key, value in retired.items() if not value) + if gone: + print(f"{len(gone)} no longer on the sheet, marked " + f"discontinued: {', '.join(gone)}") + if back: + print(f"{len(back)} back on the sheet: {', '.join(back)}") + + def select(self, games): + """The rows this pass considers. + + Applied to the whole sheet, core rows included, so --fetch + really does bound the work: core worlds are tested like every + other row, and leaving them out of the limit made a "cheap, + bounded pass" still test all eighty of them. + """ + if self.stability: + games = [game for game in games + if (game.stability or "").lower() + == self.stability.lower()] + return games[:self.fetch] if self.fetch is not None else games + + def read_sheet(self): + """The sheet's rows, split into core and downloadable. + + Discontinued rows are flagged straight after reading, so every + stage below already sees a world taken off the sheet as gone. + Core rows are tested by name but never downloaded: Archipelago + ships them. + """ + with self.stage("sheet_download"): + path = SheetFetcher(self.config, self.paths).save() + games = SheetReader(self.config, path).games() + # Every row, before any filtering: download-time identity + # checks compare against the whole sheet, core rows + # included, and so does the discontinued flag. + sheet_names = [game.name for game in games] + self.report["games_in_sheet"] = len(games) + print(f"{len(games)} games in sheet") + retired = Discontinued(self.store).mark(sheet_names) + self.report["discontinued_changed"] = retired + self.announce_retired(retired) + selected = self.select(games) + core = [game for game in selected if game.is_core] + rest = [game for game in selected if not game.is_core] + self.report["games_core"] = len(core) + self.report["games_selected"] = len(rest) + print(f"{len(core)} core games") + print(f"{len(rest)} games selected") + return rest, core, sheet_names + + def session(self): + """A session carrying the token, if there is one.""" + session = requests.Session() + token = Secrets(self.paths).github_token + if token: + session.headers["Authorization"] = f"token {token}" + return session + + def downloader(self, image, sheet_names): + return DownloadRun( + config=self.config, + paths=self.paths, + session=self.session(), + identifier=ApworldIdentifier(self.config, self.paths, image), + jobs=self.jobs, + sheet_names=sheet_names, + # Last run's resolved releases, so a game whose attempt + # failed against an unchanged release is not downloaded and + # re-identified all over again. + previous_releases=self.store.load().get( + StateKeys.GAME_RELEASES, {}), + ) + + def download(self, games, sheet_names, image, tag): + """Fetch every selected game's newest apworld.""" + reporter = DownloadReporter(self.config, len(games)) + run = self.downloader(image, sheet_names) + with self.stage("apworld_download"): + total, skipped, releases = run.all( + games, on_asset=reporter.on_asset, on_game=reporter.on_game) + self.report["assets_downloaded"] = total + self.report["games_skipped_download"] = len(skipped) + print(f"{total} assets downloaded, {len(skipped)} games skipped") + run.record_releases(releases) + self.record_versions() + DownloadFailures(self.config, self.store).record(games, skipped, tag) + orphaned = OrphanedRecords(self.store, self.paths.downloads).prune() + self.report["orphaned_records_dropped"] = len(orphaned) + if orphaned: + print(f"{len(orphaned)} result(s) dropped for apworlds no " + f"longer on disk: {', '.join(sorted(orphaned))}") + + def record_versions(self): + """One history entry per game whose apworld content changed. + + Recorded here, not by whatever publishes afterwards: this is + the moment a new version is known, and a pass that never + publishes would lose it outright - the next download overwrites + the file and no later stage can reconstruct which version + existed on which date. + """ + history = UpdateHistory(self.paths) + with self.stage("apworld_history"): + recorded = history.record_all(self.store.load(), + self.paths.downloads) + self.report["new_apworld_versions"] = len(recorded) + print(f"{len(recorded)} new apworld version(s) recorded") + + def selected_only(self, games, core_games): + """This pass's rows, named the way the batch names them. + + None unless something actually narrowed the sheet. The download + directory holds every apworld ever fetched, so an unnarrowed run + has to consider all of them - and Batch reads an explicit + selection as a reason to retest rather than serve from cache, + which would turn a plain pass into a full re-test of everything. + + Downloadable rows are matched on their download folder, core + ones on the bare sheet name, because that is how each is keyed. + """ + if self.fetch is None and not self.stability: + return None + return ([Name(game.name).directory for game in games] + + [game.name for game in core_games]) + + def test(self, image, tag, games, core_games, changed): + """Generation-test the downloaded apworlds, and the core ones. + + Core worlds ship with Archipelago, so nothing was downloaded for + them and they are named rather than mounted, but they run the + same five modes and their result is measured rather than + assumed. + + A narrowed pass tests what it narrowed to and nothing else: + --fetch bounded the download stage from the start, and leaving + the test stage unbounded meant a "cheap, bounded pass" still + generated against every apworld on disk. + """ + only = self.selected_only(games, core_games) + if only is not None: + print(f"{len(only)} selected for testing") + with self.stage("generation_tests"): + results = Batch( + config=self.config, + paths=self.paths, + settings=self.settings, + image=image, + tag=tag, + force=changed or self.force_retest, + on_progress=Reporter(self.config, bar=True), + jobs=self.jobs, + core_games=[game.name for game in core_games], + only=only, + exact_only=only is not None, + ).run() + tested = Results(results, self.config) + self.report["test_counts"] = tested.summary + self.report["test_results"] = results + print(tested.overview) + return results + + def run(self): + """Every stage, in order, returning what each one did.""" + tag, changed = self.update_archipelago() + image = self.build.test_image_tag(tag) + games, core_games, sheet_names = self.read_sheet() + self.download(games, sheet_names, image, tag) + self.test(image, tag, games, core_games, changed) + return self.report diff --git a/src/apworld_tester/templates/__init__.py b/src/apworld_tester/templates/__init__.py new file mode 100644 index 0000000..749a5dc --- /dev/null +++ b/src/apworld_tester/templates/__init__.py @@ -0,0 +1 @@ +"""The starter config shipped with the package.""" diff --git a/src/apworld_tester/templates/config.yaml b/src/apworld_tester/templates/config.yaml new file mode 100644 index 0000000..e04363a --- /dev/null +++ b/src/apworld_tester/templates/config.yaml @@ -0,0 +1,69 @@ +# Config file + +general: + # Output directory of everything a run generates, + # including the Archipelago source and downloaded APworlds + output_directory: /path/to/your/archipelago-world-tester-data + roms_directory: /path/to/your/base-roms + threads: 4 + +sheet: + # The Community Spreadsheet + spreadsheet_id: 1iuzDTOAvdoNe8Ne8i461qGNucg5OuEoF-Ikqs8aUQZw + tab_name: Playable Worlds + columns: + name: Game + link: Links & Downloads + stability: Stability + pr_status: PR Status + + +archipelago: + # Settings regarding the Archipelago repository to use for + # testing worlds. + version: "latest" + repo_url: https://github.com/ArchipelagoMW/Archipelago.git + repository: ArchipelagoMW/Archipelago + + # The Docker image basename built from that checkout + image_name: archipelago + +# repeats is what makes a verdict a pass rate rather than a single run; +# lowering it makes a run cheaper and its answer weaker, and the cache +# knows the difference. +testing: + generation_timeout: 300 + companion_min: 2 + companion_max: 5 + repeats: 10 + random_repeats: 3 + + # none: no spoiler log + # log: spoiler log, no playthrough + # playthrough: also computes the playthrough + # paths: also computes entrance paths + spoiler: playthrough + + + +# How each verdict is written in the sheet's Stability column. +stability: + labels: + stable: Stable + unstable: Minor issues + flaky: Flaky + solo_only: Solo only + broken: Broken + unknown: Unknown + +# Tag prefixes for repositories that publish several games' releases +# side by side, so the right release is picked per game. +release_overrides: + Donkey Kong Country: dkc- + "Donkey Kong Country 2: Diddy's Kong Quest": dkc2- + Mega Man X: mmx- + Mega Man X2: mmx2- + +# Worlds not tested. +blacklist: + - Ocarina of Time but it's just Master Quest Water Temple diff --git a/src/archipelago_tester/__init__.py b/src/archipelago_tester/__init__.py new file mode 100644 index 0000000..e4c6310 --- /dev/null +++ b/src/archipelago_tester/__init__.py @@ -0,0 +1,5 @@ +"""Tests Archipelago community worlds by generating seeds with them. + +The publishing half - archipelago-world-site - imports this package as a +dependency. Nothing here knows that it exists. +""" diff --git a/src/archipelago_tester/core/__init__.py b/src/archipelago_tester/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/archipelago_tester/core/config/__init__.py b/src/archipelago_tester/core/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/archipelago_tester/core/config/config.py b/src/archipelago_tester/core/config/config.py new file mode 100644 index 0000000..bfb4fa0 --- /dev/null +++ b/src/archipelago_tester/core/config/config.py @@ -0,0 +1,39 @@ +"""The project's configuration.""" + +import json +import os + + +class Config: + """config.json, read once and passed to whatever needs it.""" + + def __init__(self, values, path=None): + self.values = values + self.path = path + + @classmethod + def load(cls, path=None): + """Read config.json from the checkout.""" + path = path or cls.default_path() + with open(path, "r", encoding="utf-8") as handle: + return cls(json.load(handle), path) + + @staticmethod + def default_path(): + """config.json at the repo root, above the installed package.""" + here = os.path.dirname(os.path.abspath(__file__)) + root = os.path.dirname(os.path.dirname(os.path.dirname(here))) + return os.path.join(os.path.dirname(root), "config.json") + + def get(self, key, default=None): + """One top-level value.""" + value = self.values.get(key) + return default if value is None else value + + def section(self, name): + return self.values.get(name) or {} + + def value(self, section, key, default=None): + """One value from a section.""" + value = self.section(section).get(key) + return default if value is None else value diff --git a/src/archipelago_tester/core/config/paths.py b/src/archipelago_tester/core/config/paths.py new file mode 100644 index 0000000..8286b3d --- /dev/null +++ b/src/archipelago_tester/core/config/paths.py @@ -0,0 +1,134 @@ +"""Every path the pipeline uses.""" + +import os + + +class Paths: + """Where everything lives, derived from one Config. + + The checkout holds code and configuration; the data root holds + everything a run generates, and defaults outside the checkout + because none of that is source. Every path is absolute. + """ + + def __init__(self, config): + self.config = config + + @property + def project(self): + return os.path.dirname(self.config.path) + + @property + def data(self): + """Where everything a run generates lives.""" + configured = self.config.get( + "data_root", + "~/.local/share/archipelago-world-tester", + ) + return os.path.abspath(os.path.expanduser(configured)) + + @property + def state(self): + return os.path.join(self.data, "state.json") + + @property + def scratch_state(self): + return os.path.join(self.data, "scratch_state.json") + + @property + def lock(self): + return os.path.join(self.data, "pipeline.lock") + + @property + def downloads(self): + return os.path.join(self.data, "downloads") + + @property + def output(self): + return os.path.join(self.data, "test_output") + + @property + def history(self): + """Every apworld version this pipeline has seen, per game. + + Beside the downloads rather than inside them: that directory is + a cache and gets cleared to force a clean re-download, and this + is the one thing that cannot be rebuilt afterwards. + """ + return os.path.join(self.data, "history") + + @property + def checkout(self): + return os.path.join(self.data, "archipelago_src") + + @property + def core_worlds(self): + return os.path.join(self.checkout, "worlds") + + @property + def common_client(self): + return os.path.join(self.checkout, "CommonClient.py") + + @property + def failed_report(self): + return os.path.join(self.data, "failed_apworlds.txt") + + @property + def sheet_html(self): + return os.path.join(self.data, "playable_worlds.html") + + @property + def roms(self): + """Base ROMs for the worlds that need one.""" + configured = self.config.get("roms_directory") + if not configured: + return os.path.join(self.data, "roms") + return os.path.abspath(os.path.expanduser(configured)) + + @property + def archipelago_location(self): + """The Archipelago install the approved apworlds are placed in. + + Someone's own install rather than anything a run generates, so + it has no default: without archipelago_location there is no + install to speak of, which is a different thing from one at a + path this pipeline picked. + """ + configured = self.config.value("archipelago", "archipelago_location") + if not configured: + return None + return os.path.abspath(os.path.expanduser(configured)) + + @property + def custom_worlds(self): + """Where that install loads apworlds from.""" + location = self.archipelago_location + return location and os.path.join(location, "custom_worlds") + + @property + def env(self): + return os.path.join(self.project, ".env") + + @property + def container(self): + return os.path.join(self.project, "container") + + @property + def drivers(self): + return os.path.join(self.container, "drivers") + + @property + def test_dockerfile(self): + return os.path.join(self.container, "Dockerfile.test") + + @property + def driver(self): + return os.path.join(self.drivers, "run_test.py") + + @property + def identify_driver(self): + return os.path.join(self.drivers, "identify_apworld.py") + + @property + def verify_driver(self): + return os.path.join(self.drivers, "verify_companions.py") diff --git a/src/archipelago_tester/core/config/run_settings.py b/src/archipelago_tester/core/config/run_settings.py new file mode 100644 index 0000000..48f5e49 --- /dev/null +++ b/src/archipelago_tester/core/config/run_settings.py @@ -0,0 +1,66 @@ +"""How much testing one run asks for. + +Companions are drawn at random from the worlds Archipelago ships. Core +worlds are the safe choice: part of the build itself, so always present +and always matching the running version, unlike an apworld that might +be missing or broken for reasons of its own. A fresh draw per attempt +matters - ten runs against one fixed pair only ever test that pair. +""" + + +class RunSettings: + """The testing section, as the numbers a run is built from.""" + + def __init__(self, config): + self.config = config + + def number(self, key, default): + return int(self.config.value("testing", key, default)) + + @property + def companion_range(self): + """How many core worlds join the world under test. + + Redrawn every attempt, so ten repeats sample ten combinations. + Clamped so a mistyped config cannot ask for an empty or + backwards range. + """ + low = max(self.number("companion_min", 2), 1) + return low, max(self.number("companion_max", 5), low) + + @property + def repeats(self): + """How often the single and multi tests are repeated. + + Stability is a pass rate across runs, not one verdict: each + repeat gets a different seed, so the fill differs every time. + """ + return self.number("repeats", 10) + + @property + def random_repeats(self): + """How often the two randomized modes are repeated. + + Lower on purpose: single and multi re-roll only the seed, so + ten runs measure one thing precisely, while each randomized run + rolls a different point in the option space and three samples + say much more than one did. + """ + return self.number("random_repeats", 3) + + @property + def spoiler(self): + """2 computes the full playthrough, exercising accessibility + and reachability logic that plain generation skips - so a world + whose logic is wrong fails here rather than in a player's seed. + """ + return self.number("spoiler", 2) + + @property + def jobs(self): + """How many apworlds are tested at once. + + Each test is one isolated `docker run` and subprocess.run + releases the GIL while it waits, so threads are all this needs. + """ + return self.number("jobs", 4) diff --git a/src/archipelago_tester/core/config/secrets.py b/src/archipelago_tester/core/config/secrets.py new file mode 100644 index 0000000..a5f53b5 --- /dev/null +++ b/src/archipelago_tester/core/config/secrets.py @@ -0,0 +1,41 @@ +"""Credentials, kept out of config.json.""" + +import os + + +class Secrets: + """The .env file at the checkout's root. + + Kept out of config.json: that file gets opened, edited and shared, + and a token in it leaks the first time it is. + """ + + #: The environment variable a GitHub token is read from. + GITHUB_TOKEN = "GITHUB_TOKEN" + + def __init__(self, paths): + self.paths = paths + + def values(self): + """NAME -> value for every line in the file.""" + if not os.path.exists(self.paths.env): + return {} + values = {} + with open(self.paths.env, "r", encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + name, value = line.split("=", 1) + values[name.strip()] = value.strip().strip("\"'") + return values + + def get(self, name, default=None): + """One secret, or `default` when it is unset or empty.""" + value = self.values().get(name) + return default if value in (None, "") else value + + @property + def github_token(self): + """The GitHub token, or None.""" + return self.get(self.GITHUB_TOKEN) diff --git a/src/archipelago_tester/core/display/__init__.py b/src/archipelago_tester/core/display/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/archipelago_tester/core/display/progress.py b/src/archipelago_tester/core/display/progress.py new file mode 100644 index 0000000..ca0dfc2 --- /dev/null +++ b/src/archipelago_tester/core/display/progress.py @@ -0,0 +1,103 @@ +"""Progress reporting for the long stages.""" + +import shutil +import sys +import time + + +class Progress: + """One stage's progress, tallied by outcome as it goes. + + Both the download stage and the generation tests walk ~650 games, so + per-line output scrolls past uselessly when a run is watched. Under + cron the opposite is true - stdout is a log file, where a bar + redrawn with carriage returns is noise - so each caller supplies the + line it would have printed, and that is what a non-terminal gets. + """ + + def __init__(self, total, config=None, labels=None, stream=None): + self.total = total + self.config = config + self.labels = dict(labels or self.configured_labels()) + self.stream = stream or sys.stdout + self.counts = {} + self.started = None + + def configured_labels(self): + """Short labels for each outcome.""" + if self.config is None: + return {} + return self.config.value("display", "outcome_labels", {}) + + @property + def width(self): + """How many characters wide the bar is drawn.""" + if self.config is None: + return 28 + return int(self.config.value("display", "progress_bar_width", 28)) + + @staticmethod + def duration(seconds): + """Seconds as m:ss, or h:mm:ss past an hour.""" + seconds = int(seconds) + hours, rest = divmod(seconds, 3600) + minutes, seconds = divmod(rest, 60) + if hours: + return f"{hours}:{minutes:02d}:{seconds:02d}" + return f"{minutes:02d}:{seconds:02d}" + + @property + def tally(self): + """The running counts, labelled keys first.""" + ordered = [key for key in self.labels if self.counts.get(key)] + ordered += [key for key in self.counts if key not in self.labels] + return " ".join( + f"{self.labels.get(key, key)} {self.counts[key]}" + for key in ordered + ) + + def bar(self, fraction): + """The bar itself, filled to `fraction`.""" + filled = int(self.width * fraction) + return "#" * filled + "-" * (self.width - filled) + + def eta(self, index, elapsed): + """Time remaining at the current rate, blank once finished.""" + if not index or index >= self.total: + return "" + return f" eta {self.duration(elapsed / index * (self.total - index))}" + + def line(self, index, text): + """The whole status line for this update.""" + fraction = index / self.total if self.total else 1 + elapsed = time.monotonic() - self.started + return (f"[{self.bar(fraction)}] {index}/{self.total} " + f"{fraction * 100:3.0f}% {self.tally} " + f"{self.duration(elapsed)}{self.eta(index, elapsed)} {text}") + + def draw(self, index, text): + """Redraw the bar in place, ending the line when finished. + + Padded to overwrite a previously longer line, then trimmed to + the terminal: a wrapped line cannot be overwritten by a carriage + return, and the bar starts spilling down the screen. + """ + columns = shutil.get_terminal_size((100, 24)).columns + padded = self.line(index, text)[:columns - 1].ljust(columns - 1) + self.stream.write("\r" + padded) + self.stream.flush() + if index >= self.total: + self.stream.write("\n") + self.stream.flush() + + def update(self, index, key=None, text="", fallback_line=None): + """Record one finished item and show where the stage is.""" + if self.started is None: + self.started = time.monotonic() + if key is not None: + self.counts[key] = self.counts.get(key, 0) + 1 + if not self.stream.isatty(): + if fallback_line is not None: + print(fallback_line, file=self.stream) + return + self.draw(index, text) diff --git a/src/archipelago_tester/core/model/__init__.py b/src/archipelago_tester/core/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/archipelago_tester/core/model/apworld.py b/src/archipelago_tester/core/model/apworld.py new file mode 100644 index 0000000..40bb2fa --- /dev/null +++ b/src/archipelago_tester/core/model/apworld.py @@ -0,0 +1,120 @@ +"""The filename an apworld needs before Archipelago will import it.""" + +import json +import os +import re +import zipfile + + +class ApworldFile: + """One downloaded apworld, and what its name has to be. + + Archipelago derives the module name from the FILENAME, so a version + in the name reads as package separators and a backslash is just a + character. The right name is the zip's own package folder. + """ + + #: The extension an apworld is recognised by. + SUFFIX = ".apworld" + + def __init__(self, path): + self.path = path + + @property + def entries(self): + """Every entry name in the zip, empty if it will not open.""" + try: + with zipfile.ZipFile(self.path) as archive: + return archive.namelist() + except (OSError, zipfile.BadZipFile): + return [] + + @property + def uses_backslash_separators(self): + """Whether the zip stores its paths with backslashes. + + Invisible on Windows and fatal on Linux: zipimport swaps "/" for + the platform separator when reading, so on Windows both forms + match and on Linux the backslash entry never matches the + "pkg/__init__.py" it looks for. The author sees a working + apworld and every Linux user sees ModuleNotFoundError. + """ + names = self.entries + if not names: + return False + return (not any("/" in name for name in names) + and any("\\" in name for name in names)) + + @property + def manifest(self): + """The apworld's own archipelago.json, or None. + + Its self-declared "game" and "world_version", sitting in the + file's content regardless of which release was resolved for it - + or whether one ever was. Older apworlds predate the convention + and ship none, so this is best-effort display information rather + than something worth failing over. + """ + try: + with zipfile.ZipFile(self.path) as archive: + name = next((entry for entry in archive.namelist() + if entry.endswith("/archipelago.json")), None) + if name is None: + return None + with archive.open(name) as handle: + return json.load(handle) + except (OSError, zipfile.BadZipFile, json.JSONDecodeError, KeyError): + return None + + @property + def package_folder(self): + """The single top-level package inside the zip, or None.""" + names = [name.replace("\\", "/") for name in self.entries] + packages = { + name.split("/", 1)[0] + for name in names + if name.count("/") == 1 and name.endswith("/__init__.py") + } + return packages.pop() if len(packages) == 1 else None + + @property + def importable_name(self): + """What this file must be called for Archipelago to import it.""" + base = os.path.basename(self.path.replace("\\", "/")) + if base.lower().endswith(ApworldFile.SUFFIX): + stem = base[:-len(ApworldFile.SUFFIX)] + else: + stem = base + stem = self.package_folder or self.sanitize(stem) + return stem + ApworldFile.SUFFIX + + def sanitize(self, stem): + """A filename stem reduced to something importable.""" + base = os.path.basename(stem.replace("\\", "/")) + cleaned = re.sub(r"[^0-9A-Za-z_]", "_", base).strip("_") + if cleaned and cleaned[0].isdigit(): + cleaned = "_" + cleaned + return cleaned or "apworld" + + def prepare(self, working_directory): + """The path to hand Archipelago, and whether it was repaired.""" + if not self.uses_backslash_separators: + return self.path, False + os.makedirs(working_directory, exist_ok=True) + destination = os.path.join(working_directory, self.importable_name) + return self.write_normalized(destination), True + + def write_normalized(self, destination): + """Copy the apworld with its separators repaired.""" + with zipfile.ZipFile(self.path) as source: + with zipfile.ZipFile(destination, "w", + zipfile.ZIP_DEFLATED) as target: + for item in source.infolist(): + repaired = zipfile.ZipInfo( + filename=item.filename.replace("\\", "/"), + date_time=item.date_time, + ) + repaired.compress_type = item.compress_type + repaired.external_attr = item.external_attr + target.writestr(repaired, source.read(item.filename)) + return destination diff --git a/src/archipelago_tester/core/model/blacklist.py b/src/archipelago_tester/core/model/blacklist.py new file mode 100644 index 0000000..283631b --- /dev/null +++ b/src/archipelago_tester/core/model/blacklist.py @@ -0,0 +1,34 @@ +"""Worlds this pipeline declines to test.""" + +from archipelago_tester.core.model.name import Name + + +class Blacklist: + """Worlds not tested, and why. + + A world that times out is the most expensive thing in a run and the + least informative. Entries are permanent until removed by hand - + "do not retry" is the whole reason one is listed. + """ + + #: Recorded verdict for a world that was never tested. + OUTCOME = "unknown" + + def __init__(self, config): + self.config = config + + @property + def entries(self): + """Folded name -> reason.""" + listed = self.config.get("blacklist", {}) + return { + Name(name).slug: reason + for name, reason in listed.items() + if Name(name).slug + } + + def reason_for(self, game_name): + return self.entries.get(Name(game_name).slug) + + def detail(self, reason): + return f"not tested: {reason}" diff --git a/src/archipelago_tester/core/model/fingerprint.py b/src/archipelago_tester/core/model/fingerprint.py new file mode 100644 index 0000000..14c3c90 --- /dev/null +++ b/src/archipelago_tester/core/model/fingerprint.py @@ -0,0 +1,30 @@ +"""Content hashes, used to decide what has changed.""" + +import hashlib +import os + + +class Fingerprint: + """The content of a file or a directory, as one hash.""" + + @staticmethod + def of_file(path): + """One file's sha256, read in chunks.""" + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + @classmethod + def of_directory(cls, directory): + """One hash covering every file directly inside a directory.""" + digest = hashlib.sha256() + if not os.path.isdir(directory): + return digest.hexdigest() + for name in sorted(os.listdir(directory)): + path = os.path.join(directory, name) + if os.path.isfile(path): + digest.update(name.encode("utf-8")) + digest.update(cls.of_file(path).encode("utf-8")) + return digest.hexdigest() diff --git a/src/archipelago_tester/core/model/game.py b/src/archipelago_tester/core/model/game.py new file mode 100644 index 0000000..e1d0661 --- /dev/null +++ b/src/archipelago_tester/core/model/game.py @@ -0,0 +1,47 @@ +"""One game from the worlds sheet.""" + +import re + + +class Game: + """A sheet row: a name, its links, and what the sheet says about it.""" + + def __init__(self, name, links=None, release=None, stability=None, + pr_status=None): + self.name = name + self.links = list(links or []) + self.release = release + self.stability = stability + self.pr_status = pr_status + for link in self.links: + self.remember_release(link) + + @staticmethod + def is_release_link(link): + """Whether a link points at a GitHub releases page.""" + return bool(re.match( + r"^https?://(?:www\.)?github\.com/[^/]+/[^/]+/releases" + r"(?:[/?#]|$)", + link or "", + re.IGNORECASE, + )) + + @property + def is_core(self): + return (self.pr_status or "").strip().lower() == "core" + + def remember_release(self, link): + """Keep this link as the release URL if none was found yet.""" + if self.release is None and self.is_release_link(link): + self.release = link + return self.release + + def add_link(self, link): + """Add a link, ignoring one already recorded.""" + if link not in self.links: + self.links.append(link) + self.remember_release(link) + return self.links + + def __str__(self): + return f"{self.name} ({len(self.links)} links)" diff --git a/src/archipelago_tester/core/model/instant.py b/src/archipelago_tester/core/model/instant.py new file mode 100644 index 0000000..14b97be --- /dev/null +++ b/src/archipelago_tester/core/model/instant.py @@ -0,0 +1,35 @@ +"""One timestamp, as the moment it names.""" + +import datetime + + +class Instant: + """An ISO 8601 timestamp parsed into a comparable moment. + + Hosts do not agree on how to write the offset: GitHub sends a + trailing "Z", this pipeline's own clock writes "+00:00", and GitLab + and Gitea send a local one ("...19:55:36-04:00" is really 23:55Z). + Compared as text those sort by the numbers on the clock face rather + than by when they happened, so anything comparing two timestamps + from different sources has to parse them first. + """ + + @staticmethod + def of(text): + """One timestamp, or None when it is not a usable one. + + A naive timestamp is read as UTC: everything written here is, + and the alternative is a comparison that raises rather than one + that is merely approximate. "Z" is spelled out because + fromisoformat only accepts it from 3.11. + """ + if not text: + return None + try: + parsed = datetime.datetime.fromisoformat( + str(text).replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=datetime.timezone.utc) + return parsed diff --git a/src/archipelago_tester/core/model/modes.py b/src/archipelago_tester/core/model/modes.py new file mode 100644 index 0000000..8433e71 --- /dev/null +++ b/src/archipelago_tester/core/model/modes.py @@ -0,0 +1,22 @@ +"""The generation modes a complete result carries.""" + + +class Modes: + """What the driver runs against every world. + + Not configuration: each name is a branch in the container driver + and in Verdict, so a mode cannot be added or removed by editing a + list. Changing this would only desynchronise the cache check and + the results table from what actually ran. + """ + + SINGLE = "single" + DUPLICATE = "duplicate" + MULTI = "multi" + SINGLE_RANDOM = "single_random" + MULTI_RANDOM = "multi_random" + + ALL = (SINGLE, DUPLICATE, MULTI, SINGLE_RANDOM, MULTI_RANDOM) + + #: The modes that cannot demote a world on their own. + SECONDARY = (DUPLICATE, SINGLE_RANDOM, MULTI_RANDOM) diff --git a/src/archipelago_tester/core/model/name.py b/src/archipelago_tester/core/model/name.py new file mode 100644 index 0000000..6453438 --- /dev/null +++ b/src/archipelago_tester/core/model/name.py @@ -0,0 +1,42 @@ +"""Folding a game name down to a comparison key.""" + +import re +import unicodedata + + +class Name: + """One game name, and the two ways it gets folded. + + One implementation, because five copies once disagreed and a world + silently lost its blacklist entry. + """ + + def __init__(self, text): + self.text = text or "" + + @property + def slug(self): + """The key for matching this name against another.""" + folded = unicodedata.normalize("NFKD", self.text.lower()) + return re.compile(r"[^a-z0-9]").sub("", folded) + + @property + def report_slug(self): + return re.compile(r"[^a-z0-9]+").sub("-", self.text.lower()).strip("-") + + @property + def directory(self): + return re.sub(r'[<>:"/\\|?*]', "_", self.text).strip().rstrip(".") + + def matches(self, other): + """Whether an internally registered name means this game. + + One-directional: the internal name is usually the shorter, + canonical one and the sheet's is often longer, so checking + containment the other way would let a short internal name match + an unrelated row that happens to start the same way. + """ + if not self.text or not other: + return False + internal = Name(other).slug + return internal == self.slug or internal in self.slug diff --git a/src/archipelago_tester/core/model/verdict.py b/src/archipelago_tester/core/model/verdict.py new file mode 100644 index 0000000..1dc700a --- /dev/null +++ b/src/archipelago_tester/core/model/verdict.py @@ -0,0 +1,71 @@ +"""Where a world's test record lands on the stability ladder.""" + +from archipelago_tester.core.model.modes import Modes + + +class Verdict: + """One world's tested stability. + + The ladder: broken, unknown, flaky, solo_only, unstable, stable. + Single and multi decide the first four, because those are what a + player does; the other modes can only stop a world being stable. + """ + + #: Outcomes that count as a failure in a secondary mode. + FAILURES = ("failed", "invalid_options", "flaky") + + def __init__(self, record, config): + self.record = record or {} + self.config = config + + @property + def modes(self): + return self.record.get("tests") or {} + + @property + def never_imported(self): + return self.record.get("outcome") == "unverified" + + def outcome_of(self, mode): + return (self.modes.get(mode) or {}).get("outcome") + + @property + def primary(self): + """What the single and multi runs decide, or None if they pass.""" + single = self.outcome_of(Modes.SINGLE) + multi = self.outcome_of(Modes.MULTI) + if single == "needs_input" or multi == "needs_input": + return "unknown" + if single == "failed": + return "broken" + if single == "flaky" or multi == "flaky": + return "flaky" + if multi == "failed": + return "solo_only" + if single != "passed": + return "unknown" + return None + + @property + def secondary_failed(self): + """Whether a mode that cannot demote alone still failed.""" + return any(self.outcome_of(mode) in self.FAILURES + for mode in Modes.SECONDARY) + + @property + def name(self): + """Where this record lands on the ladder.""" + if self.never_imported: + return "broken" + if not self.modes or not self.outcome_of(Modes.SINGLE): + return "unknown" + decided = self.primary + if decided is not None: + return decided + return "unstable" if self.secondary_failed else "stable" + + @property + def label(self): + labels = self.config.value("stability", "labels", {}) + return labels.get(self.name, "Unknown") + diff --git a/src/archipelago_tester/core/state/__init__.py b/src/archipelago_tester/core/state/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/archipelago_tester/core/state/keys.py b/src/archipelago_tester/core/state/keys.py new file mode 100644 index 0000000..312ce02 --- /dev/null +++ b/src/archipelago_tester/core/state/keys.py @@ -0,0 +1,23 @@ +"""The keys this pipeline writes into state.json.""" + + +class StateKeys: + """Names of the entries in the state file. + + Not configuration: state.json is written and read only by this + pipeline, so renaming a key here would not migrate the file, it + would orphan every record already in it. + """ + + APWORLD_TESTS = "apworld_tests" + UPGRADE_SNAPSHOT = "apworld_tests_at_last_upgrade" + ARCHIPELAGO_TAG = "archipelago_tag" + ARCHIPELAGO_TAG_PUBLISHED_AT = "archipelago_tag_published_at" + PREVIOUS_ARCHIPELAGO_TAG = "previous_archipelago_tag" + COMPANION_POOL = "verified_companions" + GAME_RELEASES = "game_releases" + DISCONTINUED = "discontinued" + LAST_CHECKED_AT = "last_checked_at" + FAILED_RELEASE = "failed_release" + FAILED_REASON = "failed_reason" + STABILITY_HISTORY = "stability_history" diff --git a/src/archipelago_tester/core/state/lock.py b/src/archipelago_tester/core/state/lock.py new file mode 100644 index 0000000..c073a55 --- /dev/null +++ b/src/archipelago_tester/core/state/lock.py @@ -0,0 +1,37 @@ +"""The exclusive lock a whole run holds.""" + +import fcntl + + +class LockHeldError(RuntimeError): + """Another run already holds the lock.""" + + +class PipelineLock: + """Stops two runs, or a run and a promotion, from overlapping. + + Non-blocking: a second run fails fast rather than queuing. Queuing + would be worse - a full pass takes hours, so a queued one would + start against an Archipelago version that had moved on. + """ + + def __init__(self, path): + self.path = path + self.handle = None + + def __enter__(self): + self.handle = open(self.path, "a+") + try: + fcntl.flock(self.handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as error: + self.handle.close() + raise LockHeldError( + f"Another run already holds {self.path} - refusing to " + "start." + ) from error + return self + + def __exit__(self, *unused): + fcntl.flock(self.handle, fcntl.LOCK_UN) + self.handle.close() + return False diff --git a/src/archipelago_tester/core/state/scratch.py b/src/archipelago_tester/core/state/scratch.py new file mode 100644 index 0000000..b70fe08 --- /dev/null +++ b/src/archipelago_tester/core/state/scratch.py @@ -0,0 +1,32 @@ +"""Choosing between the live record and a scratch copy of it.""" + +import os +import shutil + + +class ScratchState: + """Where a run's results go, live or not. + + A run is an experiment unless it says otherwise: promotion and the + status page read the live file, so a hand-run test that wrote there + would put a world nobody can download into the deployable record. + + Seeding the scratch copy from the live one is what makes that cheap + rather than punishing - without it a scratch run has no recorded + Archipelago version to test against and no verified companion pool, + so it would stop for the first and re-check every core world for + the second. + """ + + def __init__(self, paths, live=False): + self.paths = paths + self.live = live + + @property + def path(self): + if self.live: + return self.paths.state + scratch = self.paths.scratch_state + if not os.path.exists(scratch) and os.path.exists(self.paths.state): + shutil.copy(self.paths.state, scratch) + return scratch diff --git a/src/archipelago_tester/core/state/store.py b/src/archipelago_tester/core/state/store.py new file mode 100644 index 0000000..8e4b39f --- /dev/null +++ b/src/archipelago_tester/core/state/store.py @@ -0,0 +1,55 @@ +"""Reading and writing the pipeline's JSON state.""" + +import json +import os +import tempfile + + +class StateStore: + """What every world last did, and the companion pool behind it.""" + + def __init__(self, path): + self.path = path + + def load(self): + return self.read(self.path, {}) + + def save(self, data): + self.write(self.path, data) + + @staticmethod + def read(path, default=None): + """A JSON file's contents, or `default` when it is absent.""" + if not os.path.exists(path): + return {} if default is None else default + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + + @staticmethod + def write(path, data): + """Write JSON so a reader never sees a partial file. + + A kill mid-write used to leave state.json truncated mid-object - + valid up to the cut point and invalid after it - silently + costing every recorded result on the next load. The batch saves + after every single test, so that window was hit often. + + os.replace is atomic: the file is always either the old complete + version or the new one. + """ + directory = os.path.dirname(os.path.abspath(path)) or "." + handle_id, temporary = tempfile.mkstemp( + dir=directory, prefix=".state_", suffix=".tmp") + try: + with os.fdopen(handle_id, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + except BaseException: + try: + os.remove(temporary) + except OSError: + pass + raise diff --git a/src/archipelago_tester/pipeline/__init__.py b/src/archipelago_tester/pipeline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/archipelago_tester/pipeline/batch/__init__.py b/src/archipelago_tester/pipeline/batch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/archipelago_tester/pipeline/batch/apworld_finder.py b/src/archipelago_tester/pipeline/batch/apworld_finder.py new file mode 100644 index 0000000..50f9a3a --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/apworld_finder.py @@ -0,0 +1,67 @@ +"""Finding the apworlds already downloaded.""" + +import os + + +class ApworldFinder: + """The download directory, as the worlds a run can test.""" + + def __init__(self, root): + self.root = root + + @staticmethod + def matches(game_name, only, exact): + if not only: + return True + if exact: + return any(t.casefold() == game_name.casefold() for t in only) + return any(t.lower() in game_name.lower() for t in only) + + def in_folder(self, game_dir): + """Every apworld directly inside one game's folder. + + A directory where a file should be is skipped rather than + crashing the batch: that is almost always docker auto-creating + an empty directory at a bind-mount source that did not exist + yet, and a fresh download replaces it next time. + """ + found = [] + for entry in sorted(os.listdir(game_dir)): + if not entry.lower().endswith(".apworld"): + continue + path = os.path.join(game_dir, entry) + if not os.path.isfile(path): + print(f"Skipping {path} - not a regular file (directory?)") + continue + found.append(path) + return found + + def find(self, only=None, exact=False): + """Every downloaded apworld, optionally narrowed to `only`. + + `only` matches on substring, which is what --only wants. That + is ambiguous when one game's name contains another's - "Hollow + Knight" also selects "Hollow Knight_ Silksong" - so a caller + that already resolved a term to a real folder passes exact. + """ + found = [] + if not os.path.isdir(self.root): + return found + for game_name in sorted(os.listdir(self.root)): + if not self.matches(game_name, only, exact): + continue + game_dir = os.path.join(self.root, game_name) + if not os.path.isdir(game_dir): + continue + found += [(game_name, path) for path in self.in_folder(game_dir)] + return found + + @property + def available(self): + return sorted({name for name, _ in self.find()}) + + def resolve(self, term): + """The folders a name selects, an exact match winning.""" + matches = sorted({name for name, _ in self.find(only=[term])}) + exact = [n for n in matches if n.casefold() == term.casefold()] + return exact or matches diff --git a/src/archipelago_tester/pipeline/batch/batch.py b/src/archipelago_tester/pipeline/batch/batch.py new file mode 100644 index 0000000..a7d2846 --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/batch.py @@ -0,0 +1,119 @@ +"""Testing every downloaded apworld and recording what each did.""" + +from archipelago_tester.core.model.fingerprint import Fingerprint +from archipelago_tester.core.state.keys import StateKeys +from archipelago_tester.core.state.store import StateStore +from archipelago_tester.pipeline.batch.companion_pool import CompanionPool +from archipelago_tester.pipeline.batch.failed_report import FailedReport +from archipelago_tester.pipeline.batch.options import Options +from archipelago_tester.pipeline.batch.planner import Planner +from archipelago_tester.pipeline.batch.runner import Runner +from archipelago_tester.pipeline.batch.work_list import WorkList + + +class Batch: + """One run: plan it, serve what is cached, test the rest. + + An apworld whose file content and Archipelago version both match + its last recorded test is served from cache. The caller forces a + full re-test when the version changes, because last week's passes + no longer apply to anything. + """ + + def __init__(self, config, paths, settings, image, tag, force=False, + on_progress=None, **asked): + self.config = config + self.paths = paths + self.settings = settings + self.image = image + self.tag = tag + self.force = force or bool(asked.get("only")) + self.on_progress = on_progress + self.options = Options(paths, settings, **asked).resolve() + self.store = StateStore(self.options["state_path"]) + + @property + def writes_live_report(self): + """Whether this run owns the "what is broken" report. + + The report is about the deployable, current-version state: a + previous-version comparison writing here would conflate "not + promotable now" with "does not work on a release nobody runs", + and a run pointed at a scratch state file is not the live + record either. + """ + return (self.options["state_key"] + == StateKeys.APWORLD_TESTS + and self.options["state_path"] == self.paths.state) + + def pool(self): + return CompanionPool( + config=self.config, + paths=self.paths, + store=self.store, + image=self.image, + tag=self.tag, + roms_directory=self.options["roms_directory"], + ).resolve() + + def plan(self, tests, fingerprint, state): + """Everything to consider, what is known, what to run. + + Blacklisted rows and the confirmation date on a cached one are + the entries written outside the runner, which is what normally + persists state - without the save here they would exist only + in memory, and a run that tested nothing would write nothing. + """ + work = WorkList( + root=self.options["root_directory"], + only=self.options["only"], + exact=self.options["exact_only"], + core_games=self.options["core_games"], + ).items() + planner = Planner( + config=self.config, + settings=self.settings, + tests=tests, + tag=self.tag, + roms_fingerprint=fingerprint, + force=self.force, + repeats=self.options["repeats"], + random_repeats=self.options["random_repeats"], + companion_range=self.options["companion_range"], + # A release published after a world's last result + # invalidates it, whatever the file on disk hashes to. + releases=state.get(StateKeys.GAME_RELEASES, {}), + ) + results, to_test = planner.plan(work) + if planner.wrote_state: + self.store.save(state) + return work, results, to_test + + def report_cached(self, results, total): + """Free to produce, so reported without waiting on the pool.""" + for index, report in enumerate(results, start=1): + if self.on_progress: + self.on_progress(index, total, report) + + def run(self): + self.options["pool"] = self.pool() + state = self.store.load() + tests = state.setdefault(self.options["state_key"], {}) + fingerprint = Fingerprint.of_directory(self.options["roms_directory"]) + work, results, to_test = self.plan(tests, fingerprint, state) + self.report_cached(results, len(work)) + if to_test: + results += Runner( + config=self.config, + paths=self.paths, + store=self.store, + options=self.options, + state=state, + tests=tests, + tag=self.tag, + image=self.image, + roms_fingerprint=fingerprint, + ).run(to_test, len(work), len(results), self.on_progress) + if self.writes_live_report: + FailedReport(state, self.paths.failed_report).write() + return results diff --git a/src/archipelago_tester/pipeline/batch/cache_check.py b/src/archipelago_tester/pipeline/batch/cache_check.py new file mode 100644 index 0000000..3d6da09 --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/cache_check.py @@ -0,0 +1,120 @@ +"""Whether a previous result still answers this run's question.""" + +import re + +from archipelago_tester.core.model.instant import Instant +from archipelago_tester.core.model.modes import Modes + + +class CacheCheck: + """One world's recorded result, against what is being asked now.""" + + def __init__(self, settings, previous): + self.settings = settings + self.previous = previous + + #: Failure text pointing at a base ROM, not the world's logic. A + #: wrong or missing ROM surfaces under many exception types, so the + #: type cannot recognise one and the message is what is left. + #: Word-bounded so "from" does not read as "rom". + ROM_FAILURE = re.compile( + r"\b(rom|md5|sha1|checksum)\b" + r"|\.(sfc|smc|z64|n64|nes|gba|gbc|gb|gen|md|iso|bin)\b", + re.IGNORECASE, + ) + + def mentions_rom(self, detail): + return bool(detail and self.ROM_FAILURE.search(str(detail))) + + def needs_rom_retry(self, roms_fingerprint): + """Whether a changed ROM folder invalidates this result. + + A missing ROM raises FileNotFoundError and lands in + "needs_input", but a WRONG one raises whatever the world throws + after reading it and lands in "failed" - so retrying only + needs_input meant replacing a bad dump could never clear the + result. A "failed" result additionally has to look like a ROM + problem: most failures carrying a stale fingerprint were fill + failures and deprecated APIs that no ROM will fix. + """ + if self.previous is None: + return False + outcome = self.previous.get("outcome") + rom_related = (outcome == "needs_input" + or (outcome == "failed" + and self.mentions_rom(self.previous.get("detail")))) + return (rom_related and self.previous.get("roms_fingerprint") + != roms_fingerprint) + + def released_since(self, released_at): + """Whether the row's apworld was published after it was tested. + + The hash catches a file whose content changed, but not a host + that offered a new one and had it turn out identical - and + "identical" is only ever this pipeline's word for it, resting + on the copy it happened to hold. A publication later than the + answer means the answer was reached before whatever is being + offered now, and testing again is the only thing that settles + it. It settles it for good, too: the new result is dated after + the release, so this stops firing until the release moves + again. + """ + if self.previous is None: + return False + released = Instant.of(released_at) + tested = Instant.of(self.previous.get("tested_at")) + if released is None or tested is None: + return False + return tested < released + + @staticmethod + def attempts(recorded_modes): + """How many attempts a cached record was reached with. + + The randomized modes used to run exactly once and carried no + count at all, so a missing value there means "one". + """ + if not recorded_modes: + return None, None + return ( + (recorded_modes.get("single") or {}).get("requested_attempts"), + (recorded_modes.get("single_random") or {}) + .get("requested_attempts"), + ) + + def answers(self, repeats, random_repeats, companion_range): + """Whether the record answers what this run is asking. + + It does not when the record predates per-mode results, when the + set of modes has changed, when it was reached with fewer + repeats - "it generated once" is not "it generated ten times + out of ten" - or when the companion range differs. A world that + never imported is exempt: "unverified" is a complete answer + about this exact file, and the file has to change for the + answer to. + """ + if self.previous is None: + return True + if self.previous.get("outcome") == "unverified": + return True + recorded = self.previous.get("tests") + if recorded is None or set(recorded) != set(Modes.ALL): + return False + single, randomized = self.attempts(recorded) + if single != repeats: + return False + if randomized is not None and randomized != random_repeats: + return False + recorded_range = (recorded.get("multi") or {}).get( + "companion_range") or [] + return list(recorded_range) == list(companion_range) + + def serves(self, current_hash, tag, roms_fingerprint, repeats, + random_repeats, companion_range, released_at=None): + """Whether this world can be served from its previous result.""" + return (self.previous is not None + and self.previous.get("hash") == current_hash + and self.previous.get("tag") == tag + and not self.needs_rom_retry(roms_fingerprint) + and not self.released_since(released_at) + and self.answers(repeats, random_repeats, companion_range)) diff --git a/src/archipelago_tester/pipeline/batch/cli.py b/src/archipelago_tester/pipeline/batch/cli.py new file mode 100644 index 0000000..90e2449 --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/cli.py @@ -0,0 +1,152 @@ +"""The command line for a full batch run.""" + +import argparse +import json + +from archipelago_tester.core.config.config import Config +from archipelago_tester.core.config.paths import Paths +from archipelago_tester.core.config.run_settings import RunSettings +from archipelago_tester.core.state.keys import StateKeys +from archipelago_tester.core.state.lock import LockHeldError, PipelineLock +from archipelago_tester.core.state.scratch import ScratchState +from archipelago_tester.core.state.store import StateStore +from archipelago_tester.pipeline.batch.batch import Batch +from archipelago_tester.pipeline.batch.reporter import Reporter +from archipelago_tester.pipeline.batch.results import Results +from archipelago_tester.pipeline.batch.version import Version +from archipelago_tester.pipeline.build.archipelago import ArchipelagoBuild + + +class Cli: + """Test every downloaded apworld, or the ones named with --only.""" + + def __init__(self): + self.config = Config.load() + self.paths = Paths(self.config) + self.settings = RunSettings(self.config) + self.build = ArchipelagoBuild(self.config, self.paths) + + def parser(self): + parser = argparse.ArgumentParser(description=self.__doc__) + parser.add_argument("--root-directory", default=self.paths.downloads) + parser.add_argument("--output-dir", default=self.paths.output) + parser.add_argument( + "--state-path", default=None, + help="Where results are recorded. Defaults to the scratch " + "file, or to the live state.json with --live; naming a " + "path here overrides both.") + parser.add_argument( + "--live", action="store_true", + help="Record into the real state.json, which promotion and " + "the status page read. Off by default: a run started " + "by hand is an experiment until it says otherwise.") + parser.add_argument("--timeout", type=int, default=None) + parser.add_argument( + "--force", action="store_true", + help="Re-test every apworld, ignoring the unchanged-since-" + "last-time skip.") + parser.add_argument( + "--only", action="append", + help="Only check games whose name contains this substring " + "(repeatable). Always retests matches regardless of cache.") + parser.add_argument( + "--update", action="store_true", + help="Resolve the newest Archipelago release, build it if " + "needed, and record it. Off by default: a new version " + "invalidates every cached result.") + parser.add_argument( + "--image", + help="Image to test against; defaults to archipelago-test:" + " for the version recorded in state.json (which " + "only --update changes).") + parser.add_argument( + "--jobs", "-j", type=int, default=self.settings.jobs, + help=f"Apworlds to test at once (default: {self.settings.jobs}).") + return parser + + def state_path(self, args): + """The file this run records into. + + An explicit --state-path wins, so a developer can keep a run + entirely to themselves; otherwise --live decides, and its + default sends results to a scratch copy of the live record. + """ + return args.state_path or ScratchState(self.paths, + live=args.live).path + + def announce(self, state_path): + """Say where results land, because the default is not the + live record and a silent run would not show which it chose.""" + if state_path == self.paths.state: + print(f"results {state_path}") + return + print(f"results {state_path} (scratch, not the live " + "record - pass --live to record for real)") + + def batch(self, args, tag, state_path): + return Batch( + config=self.config, + paths=self.paths, + settings=self.settings, + image=args.image or self.build.test_image_tag(tag), + tag=tag, + force=args.force, + on_progress=Reporter(self.config, bar=True), + root_directory=args.root_directory, + output_directory=args.output_dir, + state_path=state_path, + timeout=args.timeout, + only=args.only, + jobs=args.jobs, + ).run() + + def report(self, results): + """Print the summary a finished run ends on.""" + summary = Results(results, self.config).summary + print(f"\n{json.dumps(summary)}") + failed = [r["game"] for r in results if r.get("outcome") == "failed"] + if failed: + print(f"failed: {', '.join(failed)}") + if not results: + print("No apworlds found to test.") + return 0 if summary.get("failed", 0) == 0 else 1 + + def version(self, args, store): + """The version to test against, or None with a reason said. + + Without --update the recorded version is used as-is: nothing + upgrades behind the caller's back, because a version change + forces every world to be tested again. + """ + if args.update: + tag, source = Version(self.config, self.paths, store).prepare( + update=True) + print(f"archipelago {tag} ({source})") + return tag + tag = store.load().get(StateKeys.ARCHIPELAGO_TAG) + if not tag: + print("No Archipelago version recorded in state.json - run " + "archipelago-test --update to resolve and build one.") + return tag + + def run(self): + args = self.parser().parse_args() + state_path = self.state_path(args) + self.announce(state_path) + store = StateStore(state_path) + tag = self.version(args, store) + if not tag: + return 1 + if not args.image: + self.build.ensure_test_image(tag) + try: + with PipelineLock(self.paths.lock): + return self.report(self.batch(args, tag, state_path)) + except LockHeldError as error: + print(error) + return 1 + + @classmethod + def main(cls): + """The console script's entry point.""" + return cls().run() diff --git a/src/archipelago_tester/pipeline/batch/companion_pool.py b/src/archipelago_tester/pipeline/batch/companion_pool.py new file mode 100644 index 0000000..52bf099 --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/companion_pool.py @@ -0,0 +1,65 @@ +"""The core worlds a run may use as companions.""" + +from datetime import datetime, timezone + +from archipelago_tester.core.model.fingerprint import Fingerprint +from archipelago_tester.core.state.keys import StateKeys +from archipelago_tester.pipeline.generation.companion_check import ( + CompanionCheck, +) + + +class CompanionPool: + """Verified once per run and cached against version and ROMs. + + The answer only changes when Archipelago or the ROMs folder does, + and what it rejects would otherwise fail seeds belonging to the + world under test. + """ + + def __init__(self, config, paths, store, image, tag, roms_directory): + self.config = config + self.paths = paths + self.store = store + self.image = image + self.tag = tag + self.fingerprint = Fingerprint.of_directory(roms_directory) + + @property + def key(self): + return StateKeys.COMPANION_POOL + + def cached(self, state): + cached = state.get(self.key) or {} + if (cached.get("tag") == self.tag + and cached.get("roms_fingerprint") == self.fingerprint): + return cached.get("games") or [] + return None + + def store_result(self, state, result): + state[self.key] = { + "tag": self.tag, + "roms_fingerprint": self.fingerprint, + "games": result["verified"], + "rejected": result["rejected"], + "checked_at": datetime.now(timezone.utc).isoformat(), + } + self.store.save(state) + print(f"{len(result['verified'])} core worlds usable as companions, " + f"{len(result['rejected'])} rejected") + for game, reason in sorted(result["rejected"].items()): + print(f" {game}: {reason[:90]}") + return result["verified"] + + def resolve(self): + state = self.store.load() + cached = self.cached(state) + if cached is not None: + return cached + result = CompanionCheck(self.config, self.paths, self.image).run( + self.paths.output) + if not result.get("ran"): + print(f"companion verification failed ({result.get('detail')}) - " + "multi-game tests will draw from every core world") + return [] + return self.store_result(state, result) diff --git a/src/archipelago_tester/pipeline/batch/discontinued.py b/src/archipelago_tester/pipeline/batch/discontinued.py new file mode 100644 index 0000000..a5f321c --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/discontinued.py @@ -0,0 +1,69 @@ +"""Flagging test records whose game left the sheet.""" + +from archipelago_tester.core.model.name import Name +from archipelago_tester.core.state.keys import StateKeys + + +class Discontinued: + """A world taken off the sheet stops being one this pipeline + tracks: it should leave the status page and stop being deployed. + The record is kept and labelled rather than deleted, so its history + survives and a row that comes back - or that vanished because of a + bad sheet parse - is one flag away from being live again. + """ + + def __init__(self, store): + self.store = store + + @staticmethod + def sheet_name(key, record): + """The sheet row a record's key came from. + + A download- or core-stage record is keyed on the bare sheet + name, since nothing was ever fetched for it. Everything else is + keyed "/.apworld". The record's own "game" is + deliberately not used: that is the name Archipelago registered + the world under, which can differ from the sheet's wording. + """ + if (record or {}).get("stage") in ("download", "core"): + return key + return key.split("/", 1)[0] + + def apply(self, tests, live): + """Set or clear the flag, reporting only what moved. + + Comparison is on the folded directory name of both sides, which + is what turned the sheet name into the download folder in the + first place, so punctuation and case cannot make a still-listed + game look discontinued. An unchanged run leaves the state file + byte-identical. + """ + flag = StateKeys.DISCONTINUED + changed = {} + for key, record in tests.items(): + name = self.sheet_name(key, record) + gone = Name(name).directory not in live + if gone != bool(record.get(flag)): + changed[key] = gone + if gone: + record[flag] = True + else: + record.pop(flag, None) + return changed + + def mark(self, sheet_names): + """Flag every record whose game is no longer listed. + + Does nothing when handed no sheet names: an empty list means + the sheet was not read, not that every world on earth was + retired, and acting on it would empty the page. + """ + if not sheet_names: + return {} + state = self.store.load() + tests = state.get(StateKeys.APWORLD_TESTS, {}) + changed = self.apply( + tests, {Name(name).directory for name in sheet_names}) + if changed: + self.store.save(state) + return changed diff --git a/src/archipelago_tester/pipeline/batch/download_failures.py b/src/archipelago_tester/pipeline/batch/download_failures.py new file mode 100644 index 0000000..62c9f37 --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/download_failures.py @@ -0,0 +1,88 @@ +"""Recording games whose apworld could never be fetched.""" + +from datetime import datetime, timezone + +from archipelago_tester.core.model.name import Name +from archipelago_tester.core.state.keys import StateKeys +from archipelago_tester.pipeline.batch.record import TestRecord + + +class DownloadFailures: + """A game with no file has nothing to key a result on the way a + tested world does, so it is keyed on its own sheet name - and still + shows up as an unknown row rather than vanishing between the sheet + and the results. Real result keys always contain "/", so a bare + name cannot collide with one. + """ + + def __init__(self, config, store): + self.config = config + self.store = store + + @staticmethod + def tested_already(tests, name): + """Whether a real result already answers for this game. + + A download row exists so a game with no file still shows up + rather than vanishing between the sheet and the results. When + an apworld is on disk and was tested, nothing is vanishing - + the link broke, which the download report says, while the file + that world was last tested from is still there and still the + answer. Writing a second row then reports one game twice, and + the two rows collide at promotion because they claim the same + game name. + """ + folded = Name(name).directory + return any( + "/" in key and Name(key.split("/", 1)[0]).directory == folded + for key in tests + ) + + @staticmethod + def detail_for(reason): + return ("apworld could not be downloaded, so it was never tested: " + f"{reason}") + + def clear_stale(self, tests, games, skipped): + """Drop rows for games that downloaded this time. + + Only ever touches entries this class wrote, never a real + generation-test result. + """ + failed = {name for name, _ in skipped} + for game in games: + previous = tests.get(game.name) + if (game.name not in failed and previous is not None + and previous.get("stage") == "download"): + del tests[game.name] + + def note(self, tests, name, reason, tag, now): + """Record one failure, keeping an unchanged row's date. + + A row failing the same way as last time keeps the date of the + attempt that reached that answer and only has its last-checked + stamp moved: a test date that advanced every run would claim a + world was looked at when nothing about it was. + """ + detail = self.detail_for(reason) + previous = tests.get(name) + if (previous is not None and previous.get("stage") == "download" + and previous.get("detail") == detail): + previous["tag"] = tag + previous[StateKeys.LAST_CHECKED_AT] = now + return + tests[name] = TestRecord(name).download_failure( + detail, tag) + + def record(self, games, skipped, tag): + state = self.store.load() + tests = state.setdefault(StateKeys.APWORLD_TESTS, {}) + self.clear_stale(tests, games, skipped) + now = datetime.now(timezone.utc).isoformat() + for name, reason in skipped: + if self.tested_already(tests, name): + tests.pop(name, None) + continue + self.note(tests, name, reason, tag, now) + self.store.save(state) + return state diff --git a/src/archipelago_tester/pipeline/batch/failed_report.py b/src/archipelago_tester/pipeline/batch/failed_report.py new file mode 100644 index 0000000..0700fa7 --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/failed_report.py @@ -0,0 +1,36 @@ +"""The list of worlds that were tested and did not pass.""" + +from archipelago_tester.core.state.keys import StateKeys + + +class FailedReport: + """Written after every run, from the whole accumulated state. + + Not just this run's results: a targeted run touches a couple of + entries and the report must still reflect every other known + failure. Kept out of state.json, which is bookkeeping rather than a + report, so answering the question does not mean writing a query. + """ + + def __init__(self, state, path): + self.state = state + self.path = path + + def failed(self): + tests = self.state.get(StateKeys.APWORLD_TESTS, {}) + return sorted( + ((key, record) for key, record in tests.items() + if record.get("outcome") == "failed"), + key=lambda item: item[1].get("game") or item[0], + ) + + def write(self): + with open(self.path, "w", encoding="utf-8") as handle: + failed = self.failed() + if not failed: + handle.write("No failed apworlds.\n") + return + for key, record in failed: + detail = (record.get("detail") or "").splitlines()[0] + handle.write( + f"{record.get('game') or key} ({key}): {detail}\n") diff --git a/src/archipelago_tester/pipeline/batch/fetcher.py b/src/archipelago_tester/pipeline/batch/fetcher.py new file mode 100644 index 0000000..3f8c953 --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/fetcher.py @@ -0,0 +1,92 @@ +"""Downloading the worlds a run was asked for but does not have.""" + +import os + +import requests + +from archipelago_tester.core.config.secrets import Secrets +from archipelago_tester.pipeline.batch.apworld_finder import ApworldFinder +from archipelago_tester.pipeline.download.download_run import DownloadRun +from archipelago_tester.pipeline.sheet.fetcher import SheetFetcher +from archipelago_tester.pipeline.sheet.reader import SheetReader + + +class Fetcher: + """Fills the download directory from the sheet, on demand. + + That directory is a working directory this pipeline fills itself, + from the same sheet a full pass reads - not somewhere a user + points at their own copy. A world never fetched is work not done + yet rather than an error. + """ + + def __init__(self, config, paths, root=None): + self.config = config + self.paths = paths + self.root = root or paths.downloads + self.finder = ApworldFinder(self.root) + + def session(self): + session = requests.Session() + token = Secrets(self.paths).get("GITHUB_TOKEN") + if token: + session.headers["Authorization"] = f"token {token}" + return session + + def listed(self): + """The sheet's rows, fetching the sheet if this box has none.""" + fetcher = SheetFetcher(self.config, self.paths) + if not os.path.exists(self.paths.sheet_html): + print(f"fetching the {fetcher.tab_name} sheet " + f"-> {self.paths.sheet_html}") + fetcher.ensure() + return SheetReader(self.config, self.paths.sheet_html).games() + + @staticmethod + def row_for(term, listed): + """The one sheet row a name selects. + + An exact name wins over a substring, and a term still matching + several rows is refused rather than guessed at - downloading + and testing two worlds because a name was ambiguous is worse + than saying so. + """ + rows = [g for g in listed if term.lower() in g.name.lower()] + exact = [g for g in rows if g.name.casefold() == term.casefold()] + rows = exact or rows + if not rows: + raise LookupError( + f"{term!r} is not on the worlds sheet - nothing to " + "download and nothing to test") + if len(rows) > 1: + names = ", ".join(g.name for g in rows) + raise LookupError(f"{term!r} matches {len(rows)} sheet rows: " + f"{names}. Name one of them exactly.") + return rows[0] + + def report(self, terms): + """Say where each requested world landed, or raise.""" + for term in terms: + found = self.finder.find(only=[term]) + if not found: + raise LookupError( + f"no apworld could be obtained for {term!r}") + for name, path in found: + print(f" {name}: {path}") + + def ensure(self, terms): + """Download whichever of `terms` is not already here.""" + missing = [t for t in terms if not self.finder.find(only=[t])] + if not missing: + return [] + listed = self.listed() + wanted = [self.row_for(term, listed) for term in missing] + print(f"downloading {', '.join(g.name for g in wanted)} " + f"-> {self.root}") + run = DownloadRun(self.config, self.paths, self.session(), + root_directory=self.root) + _, skipped, _ = run.all(wanted) + for name, reason in skipped: + print(f" {name} could not be downloaded: {reason}") + self.report(missing) + return [g.name for g in wanted] diff --git a/src/archipelago_tester/pipeline/batch/options.py b/src/archipelago_tester/pipeline/batch/options.py new file mode 100644 index 0000000..41ac364 --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/options.py @@ -0,0 +1,48 @@ +"""What one batch run was asked for, with the gaps filled in.""" + +from archipelago_tester.core.state.keys import StateKeys + + +class Options: + """The caller's choices, over config.json's defaults. + + `repeats` of None means "whatever config.json says". An explicit + count trades confidence for time, and the cache check compares + against it, so a cheap run cannot masquerade as a full one. + """ + + def __init__(self, paths, settings, **asked): + self.paths = paths + self.settings = settings + self.asked = asked + + def chosen(self, key, default): + value = self.asked.get(key) + return default if value is None else value + + def counted(self, key, default): + value = self.asked.get(key) + return default if value is None else int(value) + + def resolve(self): + return { + "root_directory": self.chosen("root_directory", + self.paths.downloads), + "output_directory": self.chosen("output_directory", + self.paths.output), + "state_path": self.chosen("state_path", self.paths.state), + "roms_directory": self.chosen("roms_directory", self.paths.roms), + "jobs": self.chosen("jobs", self.settings.jobs), + "state_key": self.chosen( + "state_key", StateKeys.APWORLD_TESTS), + "companion_range": self.chosen("companion_range", + self.settings.companion_range), + "repeats": self.counted("repeats", self.settings.repeats), + "random_repeats": self.counted("random_repeats", + self.settings.random_repeats), + "spoiler": self.settings.spoiler, + "timeout": self.asked.get("timeout"), + "only": self.asked.get("only"), + "exact_only": bool(self.asked.get("exact_only")), + "core_games": self.asked.get("core_games") or (), + } diff --git a/src/archipelago_tester/pipeline/batch/orphaned_records.py b/src/archipelago_tester/pipeline/batch/orphaned_records.py new file mode 100644 index 0000000..02b6f87 --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/orphaned_records.py @@ -0,0 +1,57 @@ +"""Dropping test records whose apworld is no longer on disk.""" + +import os + +from archipelago_tester.core.state.keys import StateKeys + + +class OrphanedRecords: + """Results for files that have since gone. + + A record is keyed on the path its apworld had under the downloads + root, so a file that is removed - a release that renamed its asset, + a folder cleared by hand - leaves its result behind. Nothing reads + that record as stale: it still names a tag and a verdict, so the + status page goes on showing the world, and where the replacement + file sits beside it the game is reported twice. + + Only records with a file behind them are considered. A download- or + core-stage record is keyed on the bare sheet name and never had one, + and Discontinued is what retires those. + """ + + def __init__(self, store, downloads): + self.store = store + self.downloads = downloads + + def orphaned(self, tests): + """Every keyed-on-a-path record whose file is missing.""" + return [key for key, record in tests.items() + if "/" in key + and (record or {}).get("stage") not in ("download", "core") + and not os.path.isfile(os.path.join(self.downloads, key))] + + def safe(self): + """Whether the downloads folder looks like itself. + + An unreadable or empty root would make every record an orphan + and empty the page, which is the one outcome worth refusing: + a genuinely empty downloads directory has nothing to prune for + anyway, so declining costs nothing and guessing costs the lot. + """ + if not os.path.isdir(self.downloads): + return False + return any(os.scandir(self.downloads)) + + def prune(self): + """Remove them, returning the keys that went.""" + if not self.safe(): + return [] + state = self.store.load() + tests = state.get(StateKeys.APWORLD_TESTS, {}) + gone = self.orphaned(tests) + for key in gone: + del tests[key] + if gone: + self.store.save(state) + return gone diff --git a/src/archipelago_tester/pipeline/batch/planner.py b/src/archipelago_tester/pipeline/batch/planner.py new file mode 100644 index 0000000..94d29ec --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/planner.py @@ -0,0 +1,106 @@ +"""Splitting the work into known results and tests still to run.""" + +from archipelago_tester.core.model.blacklist import Blacklist +from archipelago_tester.core.model.name import Name +from archipelago_tester.core.state.keys import StateKeys +from archipelago_tester.pipeline.batch.cache_check import CacheCheck +from archipelago_tester.pipeline.batch.record import TestRecord + + +class Planner: + """What is already answered, and what has to be generated. + + The blacklist is checked before the cache and is unaffected by a + force flag: "do not retry" is the entire reason a world is listed, + so a flag that retried it would defeat the point. + """ + + def __init__(self, config, settings, tests, tag, roms_fingerprint, + force, repeats, random_repeats, companion_range, + releases=None): + self.config = config + self.settings = settings + self.tests = tests + self.tag = tag + self.roms_fingerprint = roms_fingerprint + self.force = force + self.repeats = repeats + self.random_repeats = random_repeats + self.companion_range = companion_range + self.blacklist = Blacklist(config) + # Keyed on the folded name: the work list names a row by + # its download folder, while game_releases keys on the + # sheet's own wording, and the two differ wherever the + # sheet uses a colon or its own capitalisation. + self.releases = { + Name(name).directory: (release or {}).get("published_at") + for name, release in (releases or {}).items() + } + self.wrote_state = False + + def record_for(self, item): + game_name, apworld_path, key, _ = item + return TestRecord(game_name, key, + is_core=apworld_path is None) + + def decline(self, item, reason): + """Write and return the result for a declined world.""" + _, _, key, current_hash = item + record = self.record_for(item) + self.tests[key] = record.blacklisted( + blacklist=self.blacklist, + current_hash=current_hash, + tag=self.tag, + reason=reason, + roms_fingerprint=self.roms_fingerprint, + ) + self.wrote_state = True + return record.as_result(self.tests[key]) + + def confirm(self, previous): + """Note that this answer was looked at and still holds. + + A cached result is not re-run, so its tested_at stays at the + run that reached it - which on a page refreshed today reads as + a world nobody has looked at in weeks rather than as a result + nothing has been able to change. This is the date that says + which of the two it is, and only a download failure was + recording it, so it was missing from every row that passed. + """ + previous[StateKeys.LAST_CHECKED_AT] = TestRecord.now() + self.wrote_state = True + + def known(self, item): + """This world's answer already, or None if it must be tested.""" + game_name, _, key, current_hash = item + reason = self.blacklist.reason_for(game_name) + if reason: + return self.decline(item, reason) + previous = self.tests.get(key) + cache = CacheCheck(self.settings, previous) + served = not self.force and cache.serves( + current_hash=current_hash, + tag=self.tag, + roms_fingerprint=self.roms_fingerprint, + repeats=self.repeats, + random_repeats=self.random_repeats, + companion_range=self.companion_range, + # A core world has no release of its own here, so this + # is None for one and the rule cannot fire: what dates + # it is the Archipelago build, already compared above. + released_at=self.releases.get(game_name), + ) + if not served: + return None + self.confirm(previous) + return self.record_for(item).as_result(previous) + + def plan(self, work): + results, to_test = [], [] + for item in work: + answer = self.known(item) + if answer is None: + to_test.append(item) + else: + results.append(answer) + return results, to_test diff --git a/src/archipelago_tester/pipeline/batch/record.py b/src/archipelago_tester/pipeline/batch/record.py new file mode 100644 index 0000000..79d1d5a --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/record.py @@ -0,0 +1,111 @@ +"""The state entry written for one world.""" + +from datetime import datetime, timezone + +from archipelago_tester.core.model.blacklist import Blacklist +from archipelago_tester.core.state.keys import StateKeys + + +class TestRecord: + """What a finished, declined or undownloadable world records. + + A core world has no file behind it, so it is marked as such and + carries no apworld path. + """ + + def __init__(self, game_name, key=None, is_core=False): + self.game_name = game_name + self.key = key + self.is_core = is_core + + @staticmethod + def now(): + return datetime.now(timezone.utc).isoformat() + + def cored(self, record): + if self.is_core: + record["stage"] = "core" + record["apworld"] = None + return record + + def from_report(self, report, current_hash, tag, internal_game, + roms_fingerprint): + """One finished test. + + Single and multi verdicts stay separate: different questions. + Every mode's own verdict is kept too, so adding one needs no + change here. "repaired" is true when the file had to be fixed + before it would load - a real result, but for a corrected copy + rather than the file the author published. + """ + return self.cored({ + "hash": current_hash, + "tag": tag, + "game": internal_game, + "outcome": report.get("outcome"), + "detail": report.get("detail"), + "elapsed_seconds": report.get("elapsed_seconds"), + "multi_outcome": report.get("multi_outcome"), + "multi_detail": report.get("multi_detail"), + "multi_elapsed_seconds": report.get("multi_elapsed_seconds"), + "multi_companions": report.get("multi_companions"), + "tests": report.get("tests"), + "roms_fingerprint": roms_fingerprint, + "tested_at": self.now(), + "repaired": bool(report.get("repaired")), + }) + + def blacklisted(self, blacklist, current_hash, tag, reason, + roms_fingerprint): + """A world this pipeline declines to test.""" + return self.cored({ + "hash": current_hash, + "tag": tag, + "game": self.game_name, + "outcome": Blacklist.OUTCOME, + "detail": blacklist.detail(reason), + "elapsed_seconds": None, + "multi_outcome": None, + "multi_detail": None, + "multi_elapsed_seconds": None, + "multi_companions": None, + "tests": None, + "roms_fingerprint": roms_fingerprint, + "tested_at": self.now(), + "blacklisted": True, + }) + + def download_failure(self, detail, tag): + """A game whose apworld could not be fetched. + + "unknown", never "failed": nothing was tested, so nothing is + known to be broken. Whether the link was a Discord message, an + unsupported host, or a release with no apworld in it, the + conclusion is the same - it could not be checked. + """ + return { + "game": self.game_name, + "apworld": None, + "outcome": "unknown", + "detail": detail, + "elapsed_seconds": None, + "stage": "download", + "tag": tag, + "hash": None, + "tested_at": self.now(), + StateKeys.LAST_CHECKED_AT: self.now(), + } + + def as_result(self, record): + """One entry of the caller-facing result list.""" + return { + "game": self.game_name, + "apworld": None if self.is_core else self.key, + "outcome": record.get("outcome"), + "detail": record.get("detail"), + "elapsed_seconds": record.get("elapsed_seconds"), + "multi_outcome": record.get("multi_outcome"), + "multi_detail": record.get("multi_detail"), + "tests": record.get("tests"), + "skipped": True, + } diff --git a/src/archipelago_tester/pipeline/batch/reporter.py b/src/archipelago_tester/pipeline/batch/reporter.py new file mode 100644 index 0000000..d61063b --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/reporter.py @@ -0,0 +1,49 @@ +"""Saying what each finished world did.""" + +from archipelago_tester.core.display.progress import Progress +from archipelago_tester.core.model.verdict import Verdict + + +class Reporter: + """One run's progress callback. + + Holds its own bar rather than a module variable: two runs in one + process would otherwise share a bar and a tally. + """ + + def __init__(self, config, bar=False): + self.config = config + self.wants_bar = bar + self.bar = None + + @staticmethod + def marker(report): + return ("cached" if report.get("skipped") + else f"{report.get('elapsed_seconds')}s") + + def verdicts(self, report): + text = f"{report['outcome']}" + multi = report.get("multi_outcome") + return f"{text} / multi:{multi}" if multi else text + + def drawn(self, index, total, report): + if self.bar is None or self.bar.total != total: + self.bar = Progress(total=total, config=self.config) + self.bar.update( + index=index, + key=report.get("outcome") or "?", + text=report.get("game") or "", + fallback_line=(f"[{index}/{total}] {report['game']}: " + f"{self.verdicts(report)} ({self.marker(report)})"), + ) + + def printed(self, index, total, report): + print(f"[{index}/{total}] {report.get('game')}: " + f"{Verdict(report, self.config).name} ({self.marker(report)})", + flush=True) + + def __call__(self, index, total, report): + if self.wants_bar: + self.drawn(index, total, report) + else: + self.printed(index, total, report) diff --git a/src/archipelago_tester/pipeline/batch/results.py b/src/archipelago_tester/pipeline/batch/results.py new file mode 100644 index 0000000..57410fd --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/results.py @@ -0,0 +1,69 @@ +"""The reports one run produced.""" + +from archipelago_tester.core.model.modes import Modes +from archipelago_tester.core.model.verdict import Verdict + + +class Results(list): + """A run's reports, printable as a table. + + A plain list of the same dicts the batch returns, so anything that + worked on those still works. The repr is the addition, because the + first thing anyone does with these is look at them. + """ + + def __init__(self, reports, config): + super().__init__(reports) + self.config = config + + def verdict(self, report): + return Verdict(report, self.config).name + + @property + def counts(self): + tally = {} + for report in self: + name = self.verdict(report) + tally[name] = tally.get(name, 0) + 1 + return dict(sorted(tally.items(), key=lambda item: -item[1])) + + @property + def summary(self): + """Outcome counts, multi verdicts under their own prefix. + + They are a separate question, so they are not merged into one + total. + """ + counts = {} + for report in self: + outcome = report.get("outcome") + counts[outcome] = counts.get(outcome, 0) + 1 + multi = report.get("multi_outcome") + if multi: + counts[f"multi:{multi}"] = counts.get(f"multi:{multi}", 0) + 1 + return counts + + def row(self, report, width): + """One world's line: name, verdict, every mode's outcome.""" + mode_results = report.get("tests") or {} + per_mode = " ".join( + f"{mode}={(mode_results.get(mode) or {}).get('outcome') or '-'}" + for mode in Modes.ALL + ) + marker = " (cached)" if report.get("skipped") else "" + return (f"{report.get('game') or '?':{width}s} " + f"{Verdict(report, self.config).label:12s} " + f"{per_mode}{marker}") + + def __repr__(self): + if not self: + return "no worlds tested" + width = max(len(report.get("game") or "") for report in self) + lines = [ + self.row(report, width) + for report in sorted(self, key=lambda r: r.get("game") or "") + ] + if len(self) > 1: + lines += ["", " ".join(f"{name}: {count}" + for name, count in self.counts.items())] + return "\n".join(lines) diff --git a/src/archipelago_tester/pipeline/batch/runner.py b/src/archipelago_tester/pipeline/batch/runner.py new file mode 100644 index 0000000..3d4a8d2 --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/runner.py @@ -0,0 +1,86 @@ +"""Running the tests the cache could not answer.""" + +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed + +from archipelago_tester.pipeline.batch.record import TestRecord +from archipelago_tester.pipeline.generation.tester import WorldTester + + +class Runner: + """Tests each outstanding world and persists what it did.""" + + def __init__(self, config, paths, store, options, state, tests, tag, + image, roms_fingerprint): + self.config = config + self.paths = paths + self.store = store + self.options = options + self.state = state + self.tests = tests + self.tag = tag + self.tester = WorldTester(config, paths, image) + self.roms_fingerprint = roms_fingerprint + self.lock = threading.Lock() + + @staticmethod + def promotion_name(report, game_name): + """The name Archipelago itself registered this world under. + + Recorded for promotion's collision check, while the report's + own "game" becomes the sheet folder name for display. A world + that never loaded has no real name, so it falls back to a + marked sheet name - otherwise every such entry would collide + under one None key and none would promote. + """ + return report.get("game") or f"unverified:{game_name}" + + def store_record(self, key, record): + """Persist after every test, not just at the end, so a killed + run does not lose the tests it already completed. + """ + with self.lock: + self.tests[key] = record + self.store.save(self.state) + + def test_one(self, game_name, apworld_path, key, current_hash): + options = self.options + report = self.tester.test( + apworld_path=apworld_path, + game=None if apworld_path else game_name, + output_dir=options["output_directory"], + timeout=options["timeout"], + companion_range=options["companion_range"], + spoiler=options["spoiler"], + repeats=options["repeats"], + companion_pool=options["pool"], + random_repeats=options["random_repeats"], + ) + record = TestRecord(game_name, key, + is_core=apworld_path is None) + self.store_record(key, record.from_report( + report=report, + current_hash=current_hash, + tag=self.tag, + internal_game=self.promotion_name(report, game_name), + roms_fingerprint=self.roms_fingerprint, + )) + report["game"] = game_name + report["apworld"] = key + report["skipped"] = False + return report + + def run(self, to_test, total, completed, on_progress): + """Run the outstanding tests, reporting as they land.""" + results = [] + reporting = threading.Lock() + with ThreadPoolExecutor(max_workers=self.options["jobs"]) as pool: + futures = [pool.submit(self.test_one, *args) for args in to_test] + for future in as_completed(futures): + report = future.result() + results.append(report) + with reporting: + completed += 1 + if on_progress: + on_progress(completed, total, report) + return results diff --git a/src/archipelago_tester/pipeline/batch/session.py b/src/archipelago_tester/pipeline/batch/session.py new file mode 100644 index 0000000..415d765 --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/session.py @@ -0,0 +1,95 @@ +"""Testing worlds by hand, from the notebook or a shell.""" + +from archipelago_tester.core.config.config import Config +from archipelago_tester.core.config.paths import Paths +from archipelago_tester.core.config.run_settings import RunSettings +from archipelago_tester.core.state.lock import PipelineLock +from archipelago_tester.core.state.scratch import ScratchState +from archipelago_tester.core.state.store import StateStore +from archipelago_tester.pipeline.batch.apworld_finder import ApworldFinder +from archipelago_tester.pipeline.batch.batch import Batch +from archipelago_tester.pipeline.batch.fetcher import Fetcher +from archipelago_tester.pipeline.batch.reporter import Reporter +from archipelago_tester.pipeline.batch.results import Results +from archipelago_tester.pipeline.batch.version import Version +from archipelago_tester.pipeline.build.archipelago import ArchipelagoBuild + + +class TestSession: + """Test one world, several, or every downloaded one. + + `live` records into the real state file - off by default, because a + hand-run test is an experiment. `cached` only affects a whole-set + run; a named world is always retested. + """ + + def __init__(self, game=None, repeats=None, random_repeats=None, jobs=2, + live=False, cached=False, root_directory=None, quiet=False, + update=False): + self.config = Config.load() + self.paths = Paths(self.config) + self.settings = RunSettings(self.config) + self.game = game + self.repeats = repeats + self.random_repeats = random_repeats + self.jobs = jobs + self.live = live + self.cached = cached + self.root = root_directory or self.paths.downloads + self.quiet = quiet + self.update = update + + @property + def state_path(self): + return ScratchState(self.paths, live=self.live).path + + def worlds(self): + """The folders to test, downloading any that are missing. + + Resolved to real folder names and matched exactly from here on, + so the set tested is the set that was just named. + """ + if self.game is None: + return None + terms = [self.game] if isinstance(self.game, str) else list(self.game) + Fetcher(self.config, self.paths, self.root).ensure(terms) + finder = ApworldFinder(self.root) + return [name for term in terms for name in finder.resolve(term)] + + def announce(self, tag, source, state_path, only): + print(f"archipelago {tag} ({source})") + print(f"project {self.paths.project}") + print(f"downloads {self.root}") + scratch = "" if self.live else " (scratch, not the live record)" + print(f"results {state_path}{scratch}") + named = ", ".join(only) if only else "every downloaded apworld" + print(f"testing {named}") + + def run(self): + """The reports, printable as a table.""" + store = StateStore(self.paths.state) + build = ArchipelagoBuild(self.config, self.paths) + tag, source = Version(self.config, self.paths, store).prepare( + update=self.update) + only = self.worlds() + build.ensure_test_image(tag) + state_path = self.state_path + if not self.quiet: + self.announce(tag, source, state_path, only) + with PipelineLock(self.paths.lock): + return Results(Batch( + config=self.config, + paths=self.paths, + settings=self.settings, + image=build.test_image_tag(tag), + tag=tag, + force=not self.cached, + on_progress=None if self.quiet else Reporter(self.config), + root_directory=self.root, + state_path=state_path, + only=only, + exact_only=only is not None, + jobs=self.jobs, + repeats=self.repeats, + random_repeats=self.random_repeats, + ).run(), self.config) diff --git a/src/archipelago_tester/pipeline/batch/version.py b/src/archipelago_tester/pipeline/batch/version.py new file mode 100644 index 0000000..9590be4 --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/version.py @@ -0,0 +1,87 @@ +"""Which Archipelago version a run tests against.""" + +from archipelago_tester.core.state.keys import StateKeys +from archipelago_tester.pipeline.build.archipelago import ArchipelagoBuild +from archipelago_tester.pipeline.build.docker import Docker + + +class Version: + """Resolve the version, and build it if this machine lacks it.""" + + def __init__(self, config, paths, store): + self.paths = paths + self.store = store + self.build = ArchipelagoBuild(config, paths) + + def resolve(self): + """The version to test, and where that choice came from. + + A pin is a decision already made, so nothing else revisits it. + Failing that the recorded version wins, then whichever image is + on this machine - a test runs entirely inside the container, so + an image already here is everything it needs. + """ + pinned = self.build.pinned_version + if pinned: + return pinned, "pinned in config.json" + recorded = self.store.load().get(StateKeys.ARCHIPELAGO_TAG) + if recorded: + return recorded, "from state.json" + local = next(iter(self.build.local_tags()), None) + return (local, "newest image on this machine") if local else (None, + None) + + def ensure_built(self, tag): + """Build what this version needs, if anything is missing. + + Building needs the Archipelago source, which is the one job the + checkout has - so this is also the only path that clones. + """ + if tag is None: + print("no Archipelago image on this machine - cloning the " + "newest release to build one (several minutes) ...") + built, _, _, _ = self.build.update_and_build(self.store) + return built, "just built" + if not Docker.image_exists(self.build.image_tag(tag)): + print(f"Archipelago {tag} is not built on this machine - " + "cloning and building it (several minutes) ...") + self.build.build_version(tag) + return tag, None + + def note_others(self, tag, source): + """Say so when an unpinned run is not using the newest image. + + An unpinned tag can come from an image that happens to be lying + around, so a run can quietly answer for a version that is not + the current one. A pinned tag was chosen deliberately. + """ + local = self.build.local_tags() + if self.build.pinned_version or not local or tag == local[0]: + return source + return f"{source}, but {local[0]} is also built here" + + def update(self): + """Resolve the newest release, build it, and record it. + + Never automatic. A new version invalidates every cached result, + so the full re-test it forces is the caller's decision, not a + side effect of running. A pinned version stays pinned: the + resolution behind this honours the pin, so an update then only + ensures that version is built. + """ + tag, previous, changed, _ = self.build.update_and_build(self.store) + if self.build.pinned_version: + return tag, "pinned in config.json" + if not changed: + return tag, "already the newest release" + if previous is None: + return tag, "newest release, recorded for the first time" + return tag, f"updated from {previous}" + + def prepare(self, update=False): + """The version to test against, built if it is missing.""" + if update: + return self.update() + tag, source = self.resolve() + tag, built = self.ensure_built(tag) + return tag, built or self.note_others(tag, source) diff --git a/src/archipelago_tester/pipeline/batch/work_list.py b/src/archipelago_tester/pipeline/batch/work_list.py new file mode 100644 index 0000000..356adec --- /dev/null +++ b/src/archipelago_tester/pipeline/batch/work_list.py @@ -0,0 +1,41 @@ +"""Everything one run will consider testing.""" + +import os + +from archipelago_tester.core.model.fingerprint import Fingerprint +from archipelago_tester.pipeline.batch.apworld_finder import ApworldFinder + + +class WorkList: + """Each world to consider, with its cache key and hash. + + A core world is tested by name with no file behind it, so it is + keyed on the bare sheet name - the same "no slash means no apworld" + convention download failures follow - and gets a stand-in hash + whose real content is the Archipelago build beside it. + """ + + #: Stands in for a core world's file hash. Its real content is + #: the Archipelago build, so the version compared beside it in the + #: cache check is what decides staleness. + CORE_HASH = "core" + + def __init__(self, root, only=None, exact=False, core_games=()): + self.root = root + self.only = only + self.exact = exact + self.core_games = core_games + + def items(self): + found = ApworldFinder(self.root).find(only=self.only, + exact=self.exact) + work = [ + (name, path, os.path.relpath(path, self.root), + Fingerprint.of_file(path)) + for name, path in found + ] + return work + [ + (name, None, name, WorkList.CORE_HASH) + for name in self.core_games + if self.only is None or name in self.only + ] diff --git a/src/archipelago_tester/pipeline/download/__init__.py b/src/archipelago_tester/pipeline/download/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/archipelago_tester/pipeline/download/asset_matcher.py b/src/archipelago_tester/pipeline/download/asset_matcher.py new file mode 100644 index 0000000..e7a8747 --- /dev/null +++ b/src/archipelago_tester/pipeline/download/asset_matcher.py @@ -0,0 +1,75 @@ +"""Choosing which asset in a release belongs to this game.""" + +import re + +from archipelago_tester.core.model.name import Name + + +class AssetMatcher: + """Matches release assets and release titles to one sheet row.""" + + def __init__(self, config, game_name): + self.config = config + self.game = Name(game_name) + + def named(self, assets, exact): + """The assets whose filename names this game.""" + matched = [] + for asset in assets: + name = Name(asset["name"].rsplit(".", 1)[0]).slug + if (name == self.game.slug) if exact else (name in self.game.slug): + matched.append(asset) + return matched + + def select(self, assets): + """The assets to download for this game.""" + if len(assets) <= 1: + return assets + for matches in (self.named(assets, True), self.named(assets, False)): + if len(matches) == 1: + return matches + return assets + + def title_slug(self, release): + """A release's own title, reduced to the game it is for.""" + title = release.get("name") or release.get("tag_name") or "" + title = re.sub(r"\bv?\d+(?:\.\d+)*\b", " ", title) + title = re.sub(r"\bapworld\b", " ", title, flags=re.IGNORECASE) + return Name(title).slug + + def uninformative(self): + """Titles that name no game at all.""" + return set(self.config.value( + "downloads", "uninformative_release_slugs", ())) + + def claimed_by(self, title, sheet_names): + """The other sheet row this title names, or None. + + Exact equality only. This is not "the title looks a bit off" - + it is positive evidence that a DIFFERENT row is named, which is + the only thing strong enough to skip a release the link points + at. "Mega Man X2" is exactly a row and is skipped when resolving + "Mega Man X"; "Mega Man X1" is nobody's row and is accepted. + """ + if not sheet_names or not self.game.text: + return None + if not title or title in self.uninformative(): + return None + if title == self.game.slug: + return None + for other in sheet_names: + slug = Name(other).slug + if slug != self.game.slug and slug == title: + return other + return None + + def release_claimed(self, release, sheet_names): + return self.claimed_by(self.title_slug(release), sheet_names) + + def identity_claimed(self, found_games, sheet_names): + """Whether a downloaded file identifies as another row's game.""" + for found in found_games or []: + other = self.claimed_by(Name(found).slug, sheet_names) + if other: + return other + return None diff --git a/src/archipelago_tester/pipeline/download/auth_error.py b/src/archipelago_tester/pipeline/download/auth_error.py new file mode 100644 index 0000000..10dd5bb --- /dev/null +++ b/src/archipelago_tester/pipeline/download/auth_error.py @@ -0,0 +1,23 @@ +"""A token failure, which is the whole run's problem.""" + +import requests + + +class GitHubAuthError(RuntimeError): + """A bad or expired token affects every remaining request.""" + + @classmethod + def matching(cls, game, error): + """This error as an auth failure, or None if it is not one. + + Raised loudly so the whole pool stops, rather than quietly + failing game after game against a token that will not work. + """ + if not isinstance(error, requests.HTTPError): + return None + response = error.response + status = response.status_code if response is not None else None + if status not in (401, 403): + return None + return cls(f"GitHub rejected the request for {game.name} with " + f"{status} - check .env / token expiry.") diff --git a/src/archipelago_tester/pipeline/download/context.py b/src/archipelago_tester/pipeline/download/context.py new file mode 100644 index 0000000..a4671d5 --- /dev/null +++ b/src/archipelago_tester/pipeline/download/context.py @@ -0,0 +1,61 @@ +"""What every download shares.""" + +import os + +from archipelago_tester.core.model.name import Name +from archipelago_tester.pipeline.download.asset_matcher import AssetMatcher + + +class DownloadContext: + """Who is downloading what, and where it goes. + + One object rather than the same six arguments threaded through + every handler and helper. + """ + + def __init__(self, config, paths, session, game, identifier=None, + sheet_names=None, root_directory=None): + self.config = config + self.paths = paths + self.session = session + self.game = game + self.identifier = identifier + self.sheet_names = sheet_names + self.root = root_directory or paths.downloads + self.matcher = AssetMatcher(config, game.name) + + @property + def target_directory(self): + return os.path.join(self.root, Name(self.game.name).directory) + + def target_path(self, filename): + return os.path.join(self.target_directory, filename) + + def setting(self, key, default=None): + return self.config.value("downloads", key, default) + + @property + def plain_headers(self): + """Headers for a host that is not GitHub. + + The session carries a GitHub token, which must never be sent to + an unrelated site. + """ + return {"Authorization": None, "Accept": "*/*"} + + @property + def json_headers(self): + """Headers for another forge's JSON API, without the token.""" + return {"Authorization": None, "Accept": "application/json"} + + def wrong_game(self, path): + """Why this file is another game's, or None to keep it.""" + if self.identifier is None: + return None + identity = self.identifier.identify(path) + if not identity.get("ran"): + return None + found = identity.get("games") or [] + if any(Name(self.game.name).matches(name) for name in found): + return None + return found or identity.get("detail") diff --git a/src/archipelago_tester/pipeline/download/download_run.py b/src/archipelago_tester/pipeline/download/download_run.py new file mode 100644 index 0000000..3d452eb --- /dev/null +++ b/src/archipelago_tester/pipeline/download/download_run.py @@ -0,0 +1,156 @@ +"""Fetching every game's apworld, concurrently.""" + +import os +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed + +import requests + +from archipelago_tester.core.state.keys import StateKeys +from archipelago_tester.core.state.store import StateStore +from archipelago_tester.pipeline.download.auth_error import GitHubAuthError +from archipelago_tester.pipeline.download.context import DownloadContext +from archipelago_tester.pipeline.download.downloader import Downloader +from archipelago_tester.pipeline.download.superseded_assets import ( + SupersededAssets, +) + + +class DownloadRun: + """One pass over the sheet's rows. + + `sheet_names` should be the FULL sheet: a file can identify as a + core row, which never reaches here. + """ + + def __init__(self, config, paths, session, root_directory=None, + identifier=None, jobs=None, sheet_names=None, + previous_releases=None): + self.config = config + self.paths = paths + self.session = session + self.root = root_directory or paths.downloads + self.identifier = identifier + self.jobs = jobs or int(config.value("downloads", "jobs", 8)) + self.sheet_names = sheet_names + self.previous_releases = previous_releases or {} + + @staticmethod + def split(games): + """The games worth a network call, and those answered now. + + A row with no link has nothing to look up, so it never takes a + slot in the thread pool. + """ + downloadable, skipped = [], [] + for game in games: + if not game.release and not game.links: + skipped.append((game.name, "no release link in the sheet")) + else: + downloadable.append(game) + return downloadable, skipped + + def context_for(self, game): + return DownloadContext( + config=self.config, + paths=self.paths, + session=self.session, + game=game, + identifier=self.identifier, + sheet_names=self.sheet_names, + root_directory=self.root, + ) + + def one(self, game): + """Download one game, which never raises for that game alone. + + A network failure is that game's own result, not the run's - + except an auth failure, which is everyone's. + """ + try: + downloaded, reason, info = Downloader( + context=self.context_for(game), + previous=self.previous_releases.get(game.name), + ).run() + except requests.RequestException as error: + auth = GitHubAuthError.matching(game, error) + if auth is not None: + raise auth from error + return game, None, str(error), None + return game, downloaded, reason, info + + @staticmethod + def source_url(game): + """Where this apworld came from, for the status page. + + game.release is only ever a real releases link, so for anything + resolved through the plain links the first one stands in. + """ + return game.release or (game.links[0] if game.links else None) + + def record(self, finished, releases, skipped, completed, total, + on_asset, on_game): + """Record one finished game and tell the caller about it. + + The release is recorded even on failure - it was still found, + so its version is worth showing. on_game is called for failures + too, unlike on_asset: a progress bar has to count every game + that finished, not only the ones that produced a file. + """ + game, downloaded, reason, info = finished + releases[game.name] = {"url": self.source_url(game), **(info or {})} + if downloaded is None: + skipped.append((game.name, reason)) + if on_game: + on_game(completed, total, game.name, False) + return 0 + paths = [path for path, _ in downloaded] + for path in SupersededAssets(os.path.dirname(paths[0]), + paths).prune(): + print(f"{game.name}: removed superseded {os.path.basename(path)}" + f" - the release no longer offers it") + for path, status in downloaded: + if on_asset: + on_asset(game.name, path, status) + if on_game: + on_game(completed, total, game.name, True) + return len(downloaded) + + @staticmethod + def result_of(future, executor): + """One finished download, stopping the pool on auth failure.""" + try: + return future.result() + except GitHubAuthError: + executor.shutdown(cancel_futures=True) + raise + + def all(self, games, on_asset=None, on_game=None): + """Every game's apworld: how many files, what was skipped, and + which release each row resolved to. + + Recording holds a lock, so on_asset always runs serialized and + a caller's own progress counter needs none of its own. + """ + self.sheet_names = self.sheet_names or [g.name for g in games] + downloadable, skipped = self.split(games) + releases, lock = {}, threading.Lock() + total = completed = 0 + with ThreadPoolExecutor(max_workers=self.jobs) as executor: + futures = [executor.submit(self.one, g) for g in downloadable] + for future in as_completed(futures): + finished = self.result_of(future, executor) + with lock: + completed += 1 + total += self.record( + finished, releases, skipped, completed, + len(downloadable), on_asset, on_game) + return total, skipped, releases + + def record_releases(self, releases, state_path=None): + """Write each game's resolved release into the state file.""" + store = StateStore(state_path or self.paths.state) + state = store.load() + state.setdefault(StateKeys.GAME_RELEASES, {}).update(releases) + store.save(state) + return state diff --git a/src/archipelago_tester/pipeline/download/downloader.py b/src/archipelago_tester/pipeline/download/downloader.py new file mode 100644 index 0000000..597689d --- /dev/null +++ b/src/archipelago_tester/pipeline/download/downloader.py @@ -0,0 +1,75 @@ +"""Fetching one sheet row's apworld.""" + +from archipelago_tester.pipeline.download.handlers.gitea_release import ( + GiteaRelease, +) +from archipelago_tester.pipeline.download.handlers.github_file import ( + GitHubFile, +) +from archipelago_tester.pipeline.download.handlers.github_release import ( + GitHubRelease, +) +from archipelago_tester.pipeline.download.handlers.github_repo import ( + GitHubRepo, +) +from archipelago_tester.pipeline.download.handlers.gitlab_file import ( + GitLabFile, +) +from archipelago_tester.pipeline.download.handlers.gitlab_release import ( + GitLabRelease, +) +from archipelago_tester.pipeline.download.handlers.plain_file import PlainFile +from archipelago_tester.pipeline.download.unusable_links import UnusableLinks +from archipelago_tester.pipeline.download.url_shapes import UrlShapes + + +class Downloader: + """Whatever shape this game's link is, get the apworld. + + A releases link is resolved directly; anything else is tried + against every handler in turn, most specific first, so an exact + file beats browsing a whole repo and the plain-URL handler can + never shadow a forge-specific one. + """ + + def __init__(self, context, previous=None): + self.context = context + self.shapes = UrlShapes(context.config) + self.previous = previous + + def handlers(self): + return ( + GitHubFile(self.shapes), + GitHubRepo(self.shapes), + GitLabFile(self.shapes), + GitLabRelease(self.shapes), + GiteaRelease(self.shapes, previous=self.previous), + PlainFile(self.shapes), + ) + + def from_release_link(self): + handler = GitHubRelease(self.shapes, previous=self.previous) + parsed = handler.parse(self.context.game.release) + if parsed is None: + return None, "not a recognized releases URL", None + return handler.download(parsed, self.context) + + def from_link(self, link): + """This link's result, or None if no handler recognised it.""" + for handler in self.handlers(): + parsed = handler.parse(link) + if parsed is not None: + return handler.download(parsed, self.context) + return None + + def run(self): + """The files downloaded, why none were, and the release info.""" + game = self.context.game + if game.release: + return self.from_release_link() + for link in game.links: + result = self.from_link(link) + if result is not None: + return result + return None, UnusableLinks(self.context.config, game.links).detail(), \ + None diff --git a/src/archipelago_tester/pipeline/download/handlers/__init__.py b/src/archipelago_tester/pipeline/download/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/archipelago_tester/pipeline/download/handlers/base.py b/src/archipelago_tester/pipeline/download/handlers/base.py new file mode 100644 index 0000000..e8283dd --- /dev/null +++ b/src/archipelago_tester/pipeline/download/handlers/base.py @@ -0,0 +1,45 @@ +"""What every link handler shares.""" + +import os + +from archipelago_tester.pipeline.download.local_file import LocalFile + + +class LinkHandler: + """One recognised shape of sheet link, and how to fetch from it. + + parse() returning None means "not my shape", which is different from + "mine, and it failed": the caller only moves on to the next link in + the first case. + """ + + def __init__(self, shapes): + self.shapes = shapes + + def parse(self, link): + raise NotImplementedError + + def download(self, parsed, context): + raise NotImplementedError + + def file(self, context, filename): + return LocalFile(context.session, context.target_path(filename)) + + def collect(self, candidates, context, fetch, verify): + """Download each candidate, dropping any that is another game.""" + downloaded, kept, rejected = [], [], [] + for candidate in candidates: + path, status = fetch(candidate) + if status in LocalFile.NEEDS_DOWNLOAD and verify: + mismatch = context.wrong_game(path) + if mismatch is not None: + os.remove(path) + rejected.append((candidate["name"], mismatch)) + continue + downloaded.append((path, status)) + kept.append(candidate) + return downloaded, kept, rejected + + def rejected_detail(self, rejected, subject): + details = "; ".join(f"{name} -> {found}" for name, found in rejected) + return f"downloaded {subject} didn't match this game: {details}" diff --git a/src/archipelago_tester/pipeline/download/handlers/gitea_release.py b/src/archipelago_tester/pipeline/download/handlers/gitea_release.py new file mode 100644 index 0000000..483ab8a --- /dev/null +++ b/src/archipelago_tester/pipeline/download/handlers/gitea_release.py @@ -0,0 +1,44 @@ +"""A releases page on a self-hosted Gitea or Forgejo instance.""" + +from archipelago_tester.pipeline.download.handlers.base import LinkHandler +from archipelago_tester.pipeline.download.release_downloader import ( + ReleaseDownloader, +) + + +class GiteaRelease(LinkHandler): + """Codeberg and friends. + + The release JSON is close enough to GitHub's that ReleaseDownloader + handles it unchanged, given headers that omit the GitHub token. + """ + + def __init__(self, shapes, previous=None): + super().__init__(shapes) + self.previous = previous + + def parse(self, link): + return self.shapes.gitea_releases(link) + + def latest(self, context, host, owner, repository): + response = context.session.get( + f"https://{host}/api/v1/repos/{owner}/{repository}" + "/releases/latest", + headers=context.json_headers, + ) + if response.status_code == 404: + return None + response.raise_for_status() + return response.json() + + def download(self, parsed, context): + host, owner, repository = parsed + release = self.latest(context, host, owner, repository) + if release is None: + return None, "no release found on this host", None + return ReleaseDownloader( + context=context, + release=release, + headers=context.plain_headers, + previous_published_at=(self.previous or {}).get("published_at"), + ).run() diff --git a/src/archipelago_tester/pipeline/download/handlers/github_file.py b/src/archipelago_tester/pipeline/download/handlers/github_file.py new file mode 100644 index 0000000..9f46027 --- /dev/null +++ b/src/archipelago_tester/pipeline/download/handlers/github_file.py @@ -0,0 +1,58 @@ +"""A link straight at an apworld committed in a GitHub repo.""" + +import os +import urllib.parse + +import requests + +from archipelago_tester.pipeline.build.archipelago import ( + ArchipelagoBuild, +) +from archipelago_tester.pipeline.download.local_file import LocalFile +from archipelago_tester.pipeline.download.handlers.base import LinkHandler + + +class GitHubFile(LinkHandler): + """A committed file, named exactly by the sheet. + + No identity check: the link names one file, so there is no second + candidate a name comparison could choose between. + """ + + def parse(self, link): + return self.shapes.github_file(link) + + def download(self, parsed, context): + """Fetch the file as it currently stands on that branch.""" + owner, repository, branch, path = parsed + filename = os.path.basename(urllib.parse.unquote(path)) + local = self.file(context, filename) + url = ("https://raw.githubusercontent.com/" + f"{owner}/{repository}/{branch}/{path}") + status = local.status(local.remote_size(url)) + if status in LocalFile.NEEDS_DOWNLOAD: + local.download(url) + published = self.published_at(context, owner, repository, path, branch) + return [(local.path, status)], None, {"published_at": published} + + def published_at(self, context, owner, repository, path, branch): + """When this file was last committed, or None. + + Best effort: a blob link has no release to read a date from, and + a download should not fail over display information. + """ + try: + response = context.session.get( + f"{ArchipelagoBuild.API_ROOT}/repos/{owner}/{repository}" + "/commits", + params={"path": path, "sha": branch, "per_page": 1}, + ) + response.raise_for_status() + commits = response.json() + except requests.RequestException: + return None + if not commits: + return None + commit = commits[0].get("commit", {}) + return ((commit.get("committer") or {}).get("date") + or (commit.get("author") or {}).get("date")) diff --git a/src/archipelago_tester/pipeline/download/handlers/github_release.py b/src/archipelago_tester/pipeline/download/handlers/github_release.py new file mode 100644 index 0000000..fc63289 --- /dev/null +++ b/src/archipelago_tester/pipeline/download/handlers/github_release.py @@ -0,0 +1,81 @@ +"""A GitHub releases page, the sheet's usual link.""" + +from archipelago_tester.pipeline.download.handlers.base import LinkHandler +from archipelago_tester.pipeline.download.release_downloader import ( + ReleaseDownloader, +) +from archipelago_tester.pipeline.download.release_identity import ( + ReleaseIdentity, +) +from archipelago_tester.pipeline.download.release_picker import ReleasePicker +from archipelago_tester.pipeline.download.walk_back import WalkBack + + +class GitHubRelease(LinkHandler): + """Resolve which release the link means, then fetch its apworld.""" + + def __init__(self, shapes, previous=None): + super().__init__(shapes) + self.previous = previous + + def parse(self, link): + return self.shapes.releases(link) + + def resolve(self, parsed, context): + """The release this link resolves to, or None. + + A hand-pinned tag prefix wins, then a link naming one exact + tag, then the search term. Runs every pass: it is the one call + that can report a new release, which is the only thing able to + change a previously failed answer. + """ + owner, repository, term, tag = parsed + picker = ReleasePicker(context, owner, repository, term) + overrides = context.config.get("release_overrides") or {} + prefix = overrides.get(context.game.name) + if prefix: + return picker.by_tag_prefix(prefix) + if tag: + return picker.by_tag(tag) + return picker.pick() + + def fetch(self, parsed, context, release): + """This release's apworld, walking back if it ships none. + + Only that one cause is walked back from. "Ships another game's + apworld" must not be, since an older release of a multi-world + repo is precisely the wrong answer there. + """ + owner, repository = parsed[0], parsed[1] + downloader = ReleaseDownloader( + context=context, + release=release, + # What this row's date already says, so a re-upload of + # unchanged bytes keeps it instead of moving it forward. + previous_published_at=(self.previous or {}).get("published_at"), + ) + downloaded, reason, info = downloader.run() + if (downloaded is not None or context.identifier is None + or downloader.cause != ReleaseDownloader.NO_APWORLD): + return downloaded, reason, info + fallback, reason = WalkBack( + context=context, + owner=owner, + repository=repository, + release=release, + ).run(reason) + return fallback if fallback is not None else (None, reason, info) + + def download(self, parsed, context): + release = self.resolve(parsed, context) + if release is None: + return None, "no release matched the search term in the link", None + identity = ReleaseIdentity(release) + repeat = identity.repeated(self.previous) + if repeat is not None: + return None, repeat, identity.failure( + {"published_at": None}, repeat) + downloaded, reason, info = self.fetch(parsed, context, release) + if downloaded is None: + return None, reason, identity.failure(info, reason) + return downloaded, reason, info diff --git a/src/archipelago_tester/pipeline/download/handlers/github_repo.py b/src/archipelago_tester/pipeline/download/handlers/github_repo.py new file mode 100644 index 0000000..333e351 --- /dev/null +++ b/src/archipelago_tester/pipeline/download/handlers/github_repo.py @@ -0,0 +1,115 @@ +"""A bare repository link, for a world never cut into a release.""" + +import os + +import requests + +from archipelago_tester.pipeline.build.archipelago import ( + ArchipelagoBuild, +) +from archipelago_tester.pipeline.download.local_file import LocalFile +from archipelago_tester.core.model.apworld import ApworldFile +from archipelago_tester.pipeline.download.handlers.base import LinkHandler + + +class GitHubRepo(LinkHandler): + """Browses the repo's own file tree for an apworld.""" + + def parse(self, link): + return self.shapes.github_repo(link) + + def resolve_branch(self, context, owner, repository, branch): + """The real default branch, so a version reads as a name. + + "HEAD" works as a ref, but resolving it means the status page + shows a branch rather than the literal string. + """ + if branch != "HEAD": + return branch + try: + response = context.session.get( + f"{ArchipelagoBuild.API_ROOT}/repos/{owner}/{repository}") + response.raise_for_status() + return response.json().get("default_branch") or "HEAD" + except requests.RequestException: + return "HEAD" + + def tree_entries(self, context, owner, repository, branch): + """Every apworld in the tree, with its size. + + One call: the trees API takes recursive=1 itself, and returns + each blob's size for free. + """ + try: + response = context.session.get( + f"{ArchipelagoBuild.API_ROOT}/repos/{owner}/{repository}" + f"/git/trees/{branch}", + params={"recursive": "1"}, + ) + response.raise_for_status() + except requests.RequestException: + return [] + return [ + (entry["path"], entry.get("size")) + for entry in response.json().get("tree", []) + if entry.get("type") == "blob" + and entry["path"].lower().endswith(ApworldFile.SUFFIX) + ] + + def candidates(self, entries): + return [ + {"name": os.path.basename(path), "path": path, "size": size} + for path, size in entries + ] + + def download(self, parsed, context): + owner, repository, branch = parsed + branch = self.resolve_branch(context, owner, repository, branch) + entries = self.tree_entries(context, owner, repository, branch) + if not entries: + return None, ("repo link has no release and no .apworld file " + "found in it"), None + chosen = context.matcher.select(self.candidates(entries)) + downloaded, kept, rejected = self.collect( + candidates=chosen, + context=context, + fetch=lambda candidate: self.fetch( + candidate, context, owner, repository, branch), + verify=len(chosen) > 1, + ) + if not downloaded: + detail = self.rejected_detail(rejected, "file(s) from repo") + return None, detail, None + published = self.published_at( + context, owner, repository, kept[0]["path"], branch) + return downloaded, None, {"published_at": published} + + def fetch(self, candidate, context, owner, repository, branch): + local = self.file(context, candidate["name"]) + status = local.status(candidate.get("size")) + if status in LocalFile.NEEDS_DOWNLOAD: + local.download( + f"https://raw.githubusercontent.com/{owner}/{repository}" + f"/{branch}/{candidate['path']}") + return local.path, status + + def published_at(self, context, owner, repository, path, branch): + """When the first kept file was last committed. + + Only the first: one API call per candidate otherwise. + """ + try: + response = context.session.get( + f"{ArchipelagoBuild.API_ROOT}/repos/{owner}/{repository}" + "/commits", + params={"path": path, "sha": branch, "per_page": 1}, + ) + response.raise_for_status() + commits = response.json() + except requests.RequestException: + return None + if not commits: + return None + commit = commits[0].get("commit", {}) + return ((commit.get("committer") or {}).get("date") + or (commit.get("author") or {}).get("date")) diff --git a/src/archipelago_tester/pipeline/download/handlers/gitlab_file.py b/src/archipelago_tester/pipeline/download/handlers/gitlab_file.py new file mode 100644 index 0000000..ded473c --- /dev/null +++ b/src/archipelago_tester/pipeline/download/handlers/gitlab_file.py @@ -0,0 +1,44 @@ +"""A link straight at an apworld committed in a GitLab repo.""" + +import os +import urllib.parse + +import requests + +from archipelago_tester.pipeline.download.local_file import LocalFile +from archipelago_tester.pipeline.download.handlers.base import LinkHandler + + +class GitLabFile(LinkHandler): + """A committed file on GitLab, named exactly by the sheet.""" + + def parse(self, link): + return self.shapes.gitlab_file(link) + + def download(self, parsed, context): + project_path, branch, path = parsed + filename = os.path.basename(urllib.parse.unquote(path)) + local = self.file(context, filename) + url = f"https://gitlab.com/{project_path}/-/raw/{branch}/{path}" + headers = context.json_headers + status = local.status(local.remote_size(url, headers=headers)) + if status in LocalFile.NEEDS_DOWNLOAD: + local.download(url, headers=headers) + published = self.published_at(context, project_path, path, branch) + return [(local.path, status)], None, {"published_at": published} + + def published_at(self, context, project_path, path, branch): + """When this file was last committed, or None.""" + encoded = urllib.parse.quote(project_path, safe="") + try: + response = context.session.get( + f"https://gitlab.com/api/v4/projects/{encoded}" + "/repository/commits", + params={"path": path, "ref_name": branch, "per_page": 1}, + headers=context.json_headers, + ) + response.raise_for_status() + commits = response.json() + except requests.RequestException: + return None + return commits[0].get("committed_date") if commits else None diff --git a/src/archipelago_tester/pipeline/download/handlers/gitlab_release.py b/src/archipelago_tester/pipeline/download/handlers/gitlab_release.py new file mode 100644 index 0000000..0913cfa --- /dev/null +++ b/src/archipelago_tester/pipeline/download/handlers/gitlab_release.py @@ -0,0 +1,74 @@ +"""A GitLab releases page.""" + +import urllib.parse + +from archipelago_tester.core.model.apworld import ApworldFile +from archipelago_tester.pipeline.download.handlers.base import LinkHandler +from archipelago_tester.pipeline.download.local_file import LocalFile + + +class GitLabRelease(LinkHandler): + """The newest release of a GitLab project. + + GitLab's assets are shaped nothing like GitHub's - a release-level + list of {name, url, direct_asset_url} with no per-asset timestamp - + so they are reshaped here rather than reusing the release path. + """ + + def parse(self, link): + return self.shapes.gitlab_releases(link) + + def latest(self, context, project_path): + """GitLab's own newest-release shortcut, or None.""" + encoded = urllib.parse.quote(project_path, safe="") + response = context.session.get( + f"https://gitlab.com/api/v4/projects/{encoded}" + "/releases/permalink/latest", + headers=context.json_headers, + ) + if response.status_code == 404: + return None + response.raise_for_status() + return response.json() + + def assets(self, release, context): + """The release's apworld links, in GitHub's asset shape.""" + links = (release.get("assets") or {}).get("links", []) + return [ + { + "name": link["name"], + "browser_download_url": (link.get("direct_asset_url") + or link["url"]), + } + for link in links + if (link.get("name") or "").lower().endswith(ApworldFile.SUFFIX) + ] + + def fetch(self, asset, context): + local = self.file(context, asset["name"]) + url = asset["browser_download_url"] + headers = context.plain_headers + status = local.status(local.remote_size(url, headers=headers)) + if status in LocalFile.NEEDS_DOWNLOAD: + local.download(url, headers=headers) + return local.path, status + + def download(self, parsed, context): + release = self.latest(context, parsed) + if release is None: + return None, "no GitLab release found for this project", None + info = {"published_at": (release.get("released_at") + or release.get("created_at"))} + assets = self.assets(release, context) + if not assets: + return None, "latest GitLab release has no .apworld asset", info + chosen = context.matcher.select(assets) + downloaded, _, rejected = self.collect( + candidates=chosen, + context=context, + fetch=lambda asset: self.fetch(asset, context), + verify=len(chosen) > 1, + ) + if not downloaded: + return None, self.rejected_detail(rejected, "asset(s)"), info + return downloaded, None, info diff --git a/src/archipelago_tester/pipeline/download/handlers/plain_file.py b/src/archipelago_tester/pipeline/download/handlers/plain_file.py new file mode 100644 index 0000000..81f2984 --- /dev/null +++ b/src/archipelago_tester/pipeline/download/handlers/plain_file.py @@ -0,0 +1,48 @@ +"""A plain URL ending in .apworld, on any host.""" + +import email.utils + +import requests + +from archipelago_tester.pipeline.download.local_file import LocalFile +from archipelago_tester.pipeline.download.handlers.base import LinkHandler + + +class PlainFile(LinkHandler): + """Any other host. Tried last, so a forge handler always wins.""" + + def parse(self, link): + return self.shapes.plain_file(link) + + def download(self, parsed, context): + url, filename = parsed + local = self.file(context, filename) + size, published = self.remote_facts(context, url) + status = local.status(size) + if status in LocalFile.NEEDS_DOWNLOAD: + local.download(url, headers=context.plain_headers) + return [(local.path, status)], None, {"published_at": published} + + def remote_facts(self, context, url): + """The size and date one HEAD request can offer. + + A plain URL has no version to compare, so size is the whole + staleness check. Neither is required. + """ + try: + response = context.session.head( + url, headers=context.plain_headers, allow_redirects=True) + response.raise_for_status() + except requests.RequestException: + return None, None + length = response.headers.get("Content-Length") + size = int(length) if length and length.isdigit() else None + return size, self.modified_at(response.headers.get("Last-Modified")) + + def modified_at(self, modified): + if not modified: + return None + try: + return email.utils.parsedate_to_datetime(modified).isoformat() + except (TypeError, ValueError): + return None diff --git a/src/archipelago_tester/pipeline/download/local_file.py b/src/archipelago_tester/pipeline/download/local_file.py new file mode 100644 index 0000000..36b6da8 --- /dev/null +++ b/src/archipelago_tester/pipeline/download/local_file.py @@ -0,0 +1,83 @@ +"""Deciding whether a local copy is still the current one.""" + +import datetime +import os + +import requests + +from archipelago_tester.core.model.instant import Instant + + +class LocalFile: + """One file on disk, against what the remote currently offers. + + "It exists" is not "it is current": an author can replace an + asset's content in place, same filename, same version. Two signals + catch that - the asset's own timestamp, and its size - and both are + needed. A replacement that happens to be the same length is + invisible to size alone, and a remote that reports no length at all + makes size no signal whatsoever. + """ + + #: The statuses that mean the file has to be fetched. The other + #: two - "verified" and "assumed" - mean the copy on disk stands. + NEEDS_DOWNLOAD = ("missing", "stale") + + def __init__(self, session, path): + self.session = session + self.path = path + + def outdated(self, remote_updated_at): + """Whether the remote was touched after this copy arrived. + + mtime is when download() wrote this file, so a remote timestamp + later than it means the copy on disk predates what is being + offered now. Without this, a release that replaced its asset + with one of the same length was never fetched, and the recorded + test result went on describing content that no longer exists - + while the page showed the new release's date beside it. + """ + stamp = Instant.of(remote_updated_at) + if stamp is None: + return False + arrived = datetime.datetime.fromtimestamp( + os.path.getmtime(self.path), datetime.timezone.utc) + return arrived < stamp + + def status(self, remote_size, remote_updated_at=None): + """Whether this file is missing, stale, verified or assumed. + + The timestamp is checked before the size, and before a missing + size falls through to "assumed": it is the stronger signal, and + the two cases it catches are exactly the ones size cannot. + """ + if not os.path.exists(self.path): + return "missing" + if self.outdated(remote_updated_at): + return "stale" + if remote_size is None: + return "assumed" + if os.path.getsize(self.path) == remote_size: + return "verified" + return "stale" + + def remote_size(self, url, headers=None): + """The size a HEAD request reports, or None.""" + try: + response = self.session.head(url, headers=headers, + allow_redirects=True) + response.raise_for_status() + except requests.RequestException: + return None + length = response.headers.get("Content-Length") + return int(length) if length and length.isdigit() else None + + def download(self, url, headers=None): + """Stream a URL into this path, creating its directory.""" + response = self.session.get(url, stream=True, headers=headers) + response.raise_for_status() + os.makedirs(os.path.dirname(self.path), exist_ok=True) + with open(self.path, "wb") as handle: + for chunk in response.iter_content(chunk_size=65536): + handle.write(chunk) + return self.path diff --git a/src/archipelago_tester/pipeline/download/release_downloader.py b/src/archipelago_tester/pipeline/download/release_downloader.py new file mode 100644 index 0000000..4e61ede --- /dev/null +++ b/src/archipelago_tester/pipeline/download/release_downloader.py @@ -0,0 +1,179 @@ +"""Fetching the apworld a release carries.""" + +import os + +from archipelago_tester.core.model.apworld import ApworldFile +from archipelago_tester.core.model.fingerprint import Fingerprint +from archipelago_tester.pipeline.download.local_file import LocalFile +from archipelago_tester.pipeline.download.zip_search import ZipSearch + + +class ReleaseDownloader: + """A GitHub-shaped release, and the apworld it ships. + + Shared with Gitea and Forgejo, whose release JSON is close enough + that the same logic applies unchanged. + """ + + #: Why run() came back empty. The caller decides what to do about + #: it from this, never from the message beside it: a release that + #: ships no apworld can be walked back from, and one that ships + #: another game's must not be. + NO_APWORLD = "no_apworld" + WRONG_GAME = "wrong_game" + + NO_APWORLD_DETAIL = "latest release has no .apworld asset" + + def __init__(self, context, release, headers=None, + require_identity=False, previous_published_at=None): + self.context = context + self.release = release + self.headers = headers + self.require_identity = require_identity + self.previous_published_at = previous_published_at + self.cause = None + + def named_assets(self): + return [ + asset for asset in self.release.get("assets", []) + if asset["name"].lower().endswith(ApworldFile.SUFFIX) + ] + + def missing_detail(self): + detail = self.NO_APWORLD_DETAIL + if any(asset["name"].lower().endswith(ZipSearch.SUFFIX) + for asset in self.release.get("assets", [])): + detail += " (checked inside its .zip asset(s) too, none found)" + return detail + + def assets(self): + """Every apworld this release offers, zips included.""" + found = self.named_assets() + if found: + return found + return ZipSearch(self.context, self.release, self.headers).assets() + + def verifies(self, assets): + """Whether a file has to prove which game it is. + + With one candidate there is nothing to choose between, and the + sheet's link points at this release - so that file is the row's + apworld whatever its World class calls itself. A walk-back + release overrides that, since the link no longer points at it. + """ + return len(assets) > 1 or self.require_identity + + @staticmethod + def touched_at(asset): + """When this asset was last written, as the remote reports it. + + The same value published_at() reports for the row, read here so + the two cannot disagree: a file whose recorded release date is + newer than the copy on disk is a file that was never fetched. + """ + return asset.get("updated_at") or asset.get("created_at") + + def fetch(self, asset): + """The asset's path, how it got there, and whether it changed. + + An asset whose timestamp moved is fetched again - the copy on + disk can no longer be trusted to be current - and only then can + the two be compared. Identical bytes mean the author re-uploaded + the same file, so nothing about this row actually changed. A + file lifted out of a zip is taken as changed: ZipSearch does its + own fetching, and there is no earlier copy here to compare to. + """ + if "extracted_path" in asset: + return asset["extracted_path"], asset["status"], True + local = LocalFile(self.context.session, + self.context.target_path(asset["name"])) + url = asset["browser_download_url"] + status = local.status( + local.remote_size(url, headers=self.headers), + remote_updated_at=self.touched_at(asset), + ) + if status not in LocalFile.NEEDS_DOWNLOAD: + return local.path, status, False + before = (Fingerprint.of_file(local.path) + if os.path.exists(local.path) else None) + local.download(url, headers=self.headers) + return local.path, status, Fingerprint.of_file(local.path) != before + + def rejection(self, path, assets): + """Why this file is not ours, or None to keep it. + + A walk-back file that registered no world at all is kept: + nothing was proven either way, and generation testing reports + the import failure far more usefully than a silent drop. + """ + identity = self.context.identifier.identify(path) + if not identity.get("ran"): + return None + found = identity.get("games") or [] + if self.require_identity and not found and len(assets) == 1: + return None + other = self.context.matcher.identity_claimed( + found, self.context.sheet_names) + matches = bool(found) and self.context.matcher.game.matches( + found[0]) + if other: + return f'{found} - belongs to the "{other}" row' + if self.verifies(assets) and not matches: + return found or identity.get("detail") + return None + + def checks(self, status, assets): + return (status in LocalFile.NEEDS_DOWNLOAD + and self.context.identifier is not None + and (self.verifies(assets) or self.context.sheet_names)) + + def published_at(self, kept): + """When the content this row now holds was published. + + Each asset carries its own timestamp; a release's own stays + fixed at creation even when its files are replaced later, so + the assets' is the one to read. But that timestamp moves for a + re-upload of identical bytes too, and then it dates the upload + rather than the world: the page would show a release newer than + the test beside it while the tested file never changed, and the + Compatibility column would call a world current on the strength + of an upload that added nothing. So only an asset whose content + actually changed sets this date, and otherwise the one already + recorded stands. With nothing recorded there is nothing to + hold, and the asset's own timestamp is the best available. + """ + stamps = [(self.touched_at(asset), changed) + for asset, changed in kept] + stamps = [pair for pair in stamps if pair[0]] + replaced = [stamp for stamp, changed in stamps if changed] + if replaced: + return max(replaced) + if self.previous_published_at: + return self.previous_published_at + return max((stamp for stamp, _ in stamps), default=None) + + def run(self): + """The files downloaded, why none were, and the release info.""" + assets = self.assets() + if not assets: + self.cause = self.NO_APWORLD + return None, self.missing_detail(), {"published_at": None} + assets = self.context.matcher.select(assets) + downloaded, kept, rejected = [], [], [] + for asset in assets: + path, status, changed = self.fetch(asset) + reason = (self.rejection(path, assets) + if self.checks(status, assets) else None) + if reason is not None: + os.remove(path) + rejected.append((asset["name"], reason)) + continue + downloaded.append((path, status)) + kept.append((asset, changed)) + if not downloaded: + self.cause = self.WRONG_GAME + details = "; ".join(f"{name} -> {found}" + for name, found in rejected) + return None, (f"downloaded asset(s) didn't match this game: " + f"{details}"), {"published_at": None} + return downloaded, None, {"published_at": self.published_at(kept)} diff --git a/src/archipelago_tester/pipeline/download/release_identity.py b/src/archipelago_tester/pipeline/download/release_identity.py new file mode 100644 index 0000000..500f79b --- /dev/null +++ b/src/archipelago_tester/pipeline/download/release_identity.py @@ -0,0 +1,55 @@ +"""Remembering which release a failure was decided on.""" + +from archipelago_tester.core.state.keys import StateKeys + + +class ReleaseIdentity: + """What makes a resolved release the same one as last run. + + Its tag and publication date, plus every asset's name and size. The + sizes matter because an author can replace an asset in place + without cutting a new tag. + """ + + def __init__(self, release): + self.release = release + + @property + def value(self): + assets = sorted( + (asset.get("name"), asset.get("size")) + for asset in self.release.get("assets") or [] + ) + return { + "tag": self.release.get("tag_name"), + "published_at": self.release.get("published_at"), + "assets": [{"name": name, "size": size} for name, size in assets], + } + + def repeated(self, previous): + """The recorded reason, if this exact release failed before. + + A release that ships no apworld, or only another row's, fails + the same way every run - and finding that out costs a download + of every zip asset plus a container run to identify what came + back. None of that can reach a different answer while the + release is unchanged. Keyed on the release rather than the + link, because the link is usually /releases/latest and a newly + published release is exactly what would fix a world in this + state. Only ever holds a verdict about content: a network error + escapes as an exception and a container that could not start + keeps the file, so neither is cached here. + """ + if not previous: + return None + if previous.get(StateKeys.FAILED_RELEASE) != self.value: + return None + return previous.get(StateKeys.FAILED_REASON) or None + + def failure(self, info, reason): + """A failure recorded against the release it was decided on.""" + return { + **(info or {}), + StateKeys.FAILED_RELEASE: self.value, + StateKeys.FAILED_REASON: reason, + } diff --git a/src/archipelago_tester/pipeline/download/release_picker.py b/src/archipelago_tester/pipeline/download/release_picker.py new file mode 100644 index 0000000..fb8156e --- /dev/null +++ b/src/archipelago_tester/pipeline/download/release_picker.py @@ -0,0 +1,151 @@ +"""Which release a sheet link means.""" + +import re +import urllib.parse + +from archipelago_tester.pipeline.build.archipelago import ( + ArchipelagoBuild, +) +from archipelago_tester.core.model.apworld import ApworldFile +from archipelago_tester.core.model.name import Name + + +class ReleasePicker: + """One repository's releases, narrowed to this game's. + + A repo dedicated to one world can answer with its latest release. A + repo hosting several has to be matched on the link's search term and + the release's own title, and must never answer with a release that + another sheet row already claims. + """ + + def __init__(self, context, owner, repository, term=None): + self.context = context + self.owner = owner + self.repository = repository + self.term = term + self.matcher = context.matcher + + @property + def repo_url(self): + return (f"{ArchipelagoBuild.API_ROOT}/repos/" + f"{self.owner}/{self.repository}") + + def all(self, max_pages=10): + """Every release, newest first, across every page. + + GitHub pages at 30 by default and several source repos hold + more than that, which would silently hide real releases from + every lookup below. + """ + releases = [] + for page in range(1, max_pages + 1): + response = self.context.session.get( + f"{self.repo_url}/releases", + params={"per_page": 100, "page": page}, + ) + response.raise_for_status() + batch = response.json() + releases.extend(batch) + if len(batch) < 100: + break + return releases + + def by_tag(self, tag): + """The release for exactly this tag, or None.""" + encoded = urllib.parse.quote(tag, safe="") + response = self.context.session.get( + f"{self.repo_url}/releases/tags/{encoded}") + if response.status_code == 404: + return None + response.raise_for_status() + return response.json() + + def by_tag_prefix(self, prefix): + """The newest release whose tag starts with this prefix.""" + for release in self.all(): + if (release.get("tag_name") or "").startswith(prefix): + return release + return None + + def claimed(self, release): + return self.matcher.release_claimed(release, self.context.sheet_names) + + def newest(self): + """GitHub's own newest full release. + + Right whenever it resolves, since it skips in-progress + prereleases. The list's first entry is the fallback for a repo + where every release is flagged prerelease, which /latest + excludes rather than relaxing. + """ + response = self.context.session.get(f"{self.repo_url}/releases/latest") + if response.status_code != 404: + response.raise_for_status() + return response.json() + releases = self.all() + return releases[0] if releases else None + + def titled_for_this_row(self): + for release in self.all(): + if self.matcher.title_slug(release) == self.matcher.game.slug: + return release + return None + + def without_a_term(self): + """The release to use when the link named none. + + "Newest in the repo" is a fine answer for a repo holding one + world and a coin flip for one holding twelve, so a newest that + is titled for somebody else's row gives way to the one titled + for this row. + """ + latest = self.newest() + if latest is None or not self.claimed(latest): + return latest + return self.titled_for_this_row() or latest + + def by_title(self, releases): + """The first release whose title holds every word of the term. + + The sheet's term is built for GitHub's own search box, which + matches per word: requiring the whole phrase rejects real terms + like "Jurassic Park Randomizer (SNES) Jurassic Park", where the + link generator repeats the game name. That looseness is what + lets a term land on a sibling world, so a release titled for + another row is skipped and the search continues. + """ + words = re.findall(r"[a-z0-9]+", self.term.lower()) + if not words: + return None + for release in releases: + title = (f"{release.get('name') or ''} " + f"{release.get('tag_name') or ''}").lower() + if all(word in title for word in words) \ + and not self.claimed(release): + return release + return None + + def by_asset_name(self, releases): + """The release carrying an asset named for this game. + + A fork mirroring many worlds often tags every release by build + date alone, so the game's name appears only in which apworlds + the release bundles. + """ + term = Name(self.term).slug + for release in releases: + for asset in release.get("assets", []): + name = asset["name"] + if not name.lower().endswith(ApworldFile.SUFFIX): + continue + if Name(name.rsplit(".", 1)[0]).slug == term: + return release + return None + + def pick(self): + """The release this link means, or None.""" + if self.term is None: + return self.without_a_term() + releases = self.all() + return self.by_title(releases) or self.by_asset_name(releases) diff --git a/src/archipelago_tester/pipeline/download/superseded_assets.py b/src/archipelago_tester/pipeline/download/superseded_assets.py new file mode 100644 index 0000000..26a46e5 --- /dev/null +++ b/src/archipelago_tester/pipeline/download/superseded_assets.py @@ -0,0 +1,58 @@ +"""Clearing apworlds a game's release no longer offers.""" + +import os + + +class SupersededAssets: + """The files left behind when an author renames their asset. + + A download writes each asset under its own filename, so a release + that renamed one leaves the previous name sitting beside it. Both + are then real files in the game's folder, and everything downstream + keys on the path rather than the game - two work items, two test + records, two rows on the status page, and a promotion that refuses + both because they claim one game name between them. + + Only ever run for a game whose download succeeded: with nothing + fetched there is no current set to compare against, and pruning on + a failed network call would delete the copy that still works. + """ + + #: What this class considers. A game's folder holds nothing else, + #: but naming it here keeps a stray file from being deleted on the + #: strength of living in the wrong directory. + SUFFIX = ".apworld" + + def __init__(self, directory, kept): + self.directory = directory + self.kept = {os.path.abspath(path) for path in kept} + + def stale(self): + """Every apworld in the folder that this download did not write.""" + if not self.kept or not os.path.isdir(self.directory): + return [] + found = [] + for entry in sorted(os.listdir(self.directory)): + if not entry.lower().endswith(self.SUFFIX): + continue + path = os.path.join(self.directory, entry) + if os.path.isfile(path) and os.path.abspath(path) not in self.kept: + found.append(path) + return found + + def prune(self): + """Delete them, returning what went. + + A file that cannot be removed is not worth failing a download + over - it means the next run sees a duplicate, which is what + was already happening. + """ + removed = [] + for path in self.stale(): + try: + os.remove(path) + except OSError as error: + print(f"could not remove superseded {path}: {error}") + continue + removed.append(path) + return removed diff --git a/src/archipelago_tester/pipeline/download/unusable_links.py b/src/archipelago_tester/pipeline/download/unusable_links.py new file mode 100644 index 0000000..4043777 --- /dev/null +++ b/src/archipelago_tester/pipeline/download/unusable_links.py @@ -0,0 +1,54 @@ +"""Why none of a game's links could be downloaded from.""" + +import urllib.parse + + +class UnusableLinks: + """Categorise links no handler recognised. + + Reaching here means every recognised shape was tried against every + link, so the status page can say something concrete instead of a + generic "failed". + """ + + def __init__(self, config, links): + self.config = config + self.links = links + + @property + def unreachable(self): + """Hosts known in advance never to be downloadable. + + A Discord message link is not a public file however it is + fetched, unlike an unknown host that merely has no handler yet. + """ + return self.config.value("downloads", "unreachable_link_hosts") or {} + + def domains(self): + """Each link's host, or a reason it has none.""" + found = [] + for link in self.links: + parsed = urllib.parse.urlparse(link) + if parsed.scheme not in ("http", "https"): + protocol = parsed.scheme or "(none)" + return None, f"unsupported link protocol {protocol!r}: {link}" + if not parsed.netloc: + return None, f"not a valid URL: {link}" + netloc = parsed.netloc.lower() + found.append(netloc[4:] if netloc.startswith("www.") else netloc) + return found, None + + def detail(self): + domains, problem = self.domains() + if problem is not None: + return problem + for domain in domains: + if domain in self.unreachable: + return self.unreachable[domain] + if "github.com" in domains: + return ("GitHub link found, but not a recognized " + "releases/repo/file URL") + if "gitlab.com" in domains: + return "GitLab link found, but not a recognized releases/blob URL" + hosts = ", ".join(sorted(set(domains))) + return f"no supported download source (custom host: {hosts})" diff --git a/src/archipelago_tester/pipeline/download/update_history.py b/src/archipelago_tester/pipeline/download/update_history.py new file mode 100644 index 0000000..2596b54 --- /dev/null +++ b/src/archipelago_tester/pipeline/download/update_history.py @@ -0,0 +1,168 @@ +"""Every distinct apworld file this pipeline has seen, per game. + +One file per game, written only when that game gains content it has not +seen before - so a run touches the handful of games that actually +changed rather than rewriting one shared blob. Deliberately NOT under +downloads/: that directory is a cache and gets cleared to force a clean +re-download, which is harmless for everything else in it. History is the +one thing that cannot be rebuilt afterwards - which version existed on +which date has no other source once it is gone. +""" + +import os +from datetime import datetime, timezone + +from archipelago_tester.core.model.apworld import ApworldFile +from archipelago_tester.core.model.fingerprint import Fingerprint +from archipelago_tester.core.model.name import Name +from archipelago_tester.core.state.keys import StateKeys +from archipelago_tester.core.state.store import StateStore + + +class UpdateHistory: + """What each game's apworld was, and when. + + Identity is the file's sha256, so a re-download of unchanged content + adds nothing and recording is safe to repeat within a run. The + game's sheet name is stored inside the file as well as encoded in + its name, because the filename is a folded directory name and a + sheet rename would otherwise leave a file nothing could identify. + """ + + def __init__(self, paths, directory=None): + self.directory = directory or paths.history + + def path_for(self, game_name): + folded = Name(game_name).directory + return os.path.join(self.directory, f"{folded}.json") + + def load(self, game_name): + return StateStore.read( + self.path_for(game_name), + {"game": game_name, "watching_since": None, + "baseline_at": None, "files": []}) + + def save(self, game_name, history): + os.makedirs(self.directory, exist_ok=True) + StateStore(self.path_for(game_name)).save(history) + + #: Everything recorded before this field existed was gathered from + #: whatever the hosts happened to say, at whatever moment the file + #: was first downloaded, and none of it measures how often a world + #: changes under observation. The first pass to touch a game draws + #: a line: that instant is the game's one starting point, and only + #: changes seen after it count. See UpdateCadence, which reads it. + @staticmethod + def watching_since(history, stamp): + """When this pipeline began watching this game. + + The earliest content it has already recorded, so a file written + before this field existed still gets an honest answer rather + than a window that starts the day the field was added. Only a + game with no history at all starts watching now. + """ + seen = [item.get("first_seen_at") for item in history["files"] + if item.get("first_seen_at")] + return min(seen) if seen else stamp + + def entry_for(self, apworld_path, sha256, released_at, source_url, now): + """One file's record. + + `released_at` is the upload date the download stage saw; + first_seen_at is when this pipeline observed it, which is the + only honest timestamp for a file whose host reports no date. + """ + manifest = ApworldFile(apworld_path).manifest or {} + return { + "version": manifest.get("world_version"), + "released_at": released_at, + "first_seen_at": (now or datetime.now(timezone.utc)).isoformat(), + "sha256": sha256, + "filename": os.path.basename(apworld_path), + "source_url": source_url, + } + + def record_file(self, game_name, apworld_path, sha256, released_at, + source_url=None, now=None): + """Add this file to the game's history if its content is new. + + A game whose content has not changed still records that it was + looked at. How often a world updates is only meaningful against + how long it has been watched, and without that a game seen once + and a game seen all year are indistinguishable. + """ + history = self.load(game_name) + now = now or datetime.now(timezone.utc) + opened = not history.get("watching_since") + if opened: + history["watching_since"] = self.watching_since( + history, now.isoformat()) + if not history.get("baseline_at"): + opened = True + history["baseline_at"] = now.isoformat() + if any(item.get("sha256") == sha256 for item in history["files"]): + if opened: + self.save(game_name, history) + return None + entry = self.entry_for(apworld_path, sha256, released_at, + source_url, now) + history["game"] = game_name + history["files"].append(entry) + # Oldest first. released_at is missing often enough that it + # cannot be the only key - first_seen_at always exists and + # breaks those ties in the order the pipeline saw them. + history["files"].sort( + key=lambda item: (item.get("released_at") or "", + item.get("first_seen_at") or "")) + self.save(game_name, history) + return entry + + def files_in(self, game_directory): + return [ + name for name in sorted(os.listdir(game_directory)) + if name.lower().endswith(ApworldFile.SUFFIX) + and os.path.isfile(os.path.join(game_directory, name)) + ] + + def record_all(self, state, root_directory, now=None): + """Record every apworld on disk that is not yet in history. + + Hashes the files directly rather than reading apworld_tests: + that dict only gains a hash once a world has been TESTED, so a + file downloaded this run would not appear until the next one - + and this runs right after downloading, which is the point at + which a new version is known. + """ + releases = state.get(StateKeys.GAME_RELEASES) or {} + # game_releases is keyed by the sheet name while the download + # folder is that name folded - map one to the other rather than + # assuming they match as written. + by_folder = {Name(name).directory: info + for name, info in releases.items()} + added = [] + if not os.path.isdir(root_directory): + return added + for folder in sorted(os.listdir(root_directory)): + game_directory = os.path.join(root_directory, folder) + if not os.path.isdir(game_directory): + continue + added += self.record_folder( + folder, game_directory, by_folder.get(folder) or {}, now) + return added + + def record_folder(self, folder, game_directory, release, now): + """Every new apworld in one game's download folder.""" + added = [] + for filename in self.files_in(game_directory): + path = os.path.join(game_directory, filename) + entry = self.record_file( + game_name=folder, + apworld_path=path, + sha256=Fingerprint.of_file(path), + released_at=release.get("published_at"), + source_url=release.get("url"), + now=now, + ) + if entry: + added.append((folder, entry)) + return added diff --git a/src/archipelago_tester/pipeline/download/url_shapes.py b/src/archipelago_tester/pipeline/download/url_shapes.py new file mode 100644 index 0000000..2a2bec4 --- /dev/null +++ b/src/archipelago_tester/pipeline/download/url_shapes.py @@ -0,0 +1,98 @@ +"""Recognising the shapes a sheet link can take.""" + +import os +import re +import urllib.parse + +from archipelago_tester.core.model.apworld import ApworldFile + + +class UrlShapes: + """The link shapes this pipeline knows how to fetch from.""" + + def __init__(self, config): + self.config = config + + def releases(self, url): + """Owner, repo, search term and tag from a releases link. + + A link naming one exact tag is read as such: without it, a repo + hosting several worlds would fall back to "latest", which drifts + to whichever world was tagged most recently. + """ + match = re.match( + r"^https?://(?:www\.)?github\.com/([^/]+)/([^/]+)/releases", + url or "", re.IGNORECASE) + if match is None: + return None + parsed = urllib.parse.urlparse(url) + terms = urllib.parse.parse_qs(parsed.query).get("q", []) + tag_match = re.match( + r"^/[^/]+/[^/]+/releases/tag/([^/?#]+)", parsed.path, + re.IGNORECASE) + return ( + match.group(1), + match.group(2), + terms[0].strip('"') if terms else None, + urllib.parse.unquote(tag_match.group(1)) if tag_match else None, + ) + + def github_file(self, url): + """Owner, repo, branch and path from a committed-file link.""" + match = re.match( + r"^https?://(?:www\.)?github\.com/([^/]+)/([^/]+)" + r"/(?:blob|raw)/([^/]+)/(.+\.apworld)$", + url or "", re.IGNORECASE) + return match.groups() if match else None + + def github_repo(self, url): + """Owner, repo and branch from a bare repository link. + + Deliberately strict, so it does not swallow issues or wiki + links: those should keep falling through to the generic "not a + releases URL" message rather than triggering a tree search that + could never succeed. + """ + match = re.match( + r"^https?://(?:www\.)?github\.com/([^/]+)/([^/]+?)" + r"(?:/tree/([^/?#]+))?/?$", + url or "", re.IGNORECASE) + if match is None: + return None + owner, repository, branch = match.groups() + return owner, repository, branch or "HEAD" + + def gitlab_file(self, url): + """Project path, branch and file path from a GitLab blob link.""" + match = re.match( + r"^https?://(?:www\.)?gitlab\.com/([^/]+(?:/[^/]+)*?)" + r"/-/blob/([^/]+)/(.+\.apworld)$", + url or "", re.IGNORECASE) + return match.groups() if match else None + + def gitlab_releases(self, url): + """The project path from a GitLab releases link.""" + match = re.match( + r"^https?://(?:www\.)?gitlab\.com/([^/]+(?:/[^/]+)*?)" + r"/-/releases(?:[/?#]|$)", + url or "", re.IGNORECASE) + return match.group(1) if match else None + + def gitea_releases(self, url): + """Host, owner and repo from a Gitea-style releases link.""" + match = re.match( + r"^https?://(?!(?:www\.)?(?:github|gitlab)\.com)" + r"([^/]+)/([^/]+)/([^/]+)/releases(?:[/?#]|$)", + url or "", re.IGNORECASE) + return match.groups() if match else None + + def plain_file(self, url): + """A URL ending in .apworld on any host, and its filename.""" + parsed = urllib.parse.urlparse(url or "") + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return None + path = urllib.parse.unquote(parsed.path) + if not path.lower().endswith(ApworldFile.SUFFIX): + return None + filename = os.path.basename(path) + return (url, filename) if filename != ApworldFile.SUFFIX else None diff --git a/src/archipelago_tester/pipeline/download/walk_back.py b/src/archipelago_tester/pipeline/download/walk_back.py new file mode 100644 index 0000000..7afee7e --- /dev/null +++ b/src/archipelago_tester/pipeline/download/walk_back.py @@ -0,0 +1,77 @@ +"""Falling back to an older release that still ships an apworld.""" + +import requests + +from archipelago_tester.core.model.apworld import ApworldFile +from archipelago_tester.pipeline.download.release_downloader import ( + ReleaseDownloader, +) +from archipelago_tester.pipeline.download.release_picker import ReleasePicker + + +class WalkBack: + """One narrow rescue: the newest release forgot the apworld. + + An author cuts a release and re-attaches only a mod zip, leaving + the world perfectly downloadable one release back. Every other + failure is left alone on purpose - in particular "did not match + this game", where an older release holding a different game's + apworld is precisely the wrong answer. + """ + + def __init__(self, context, owner, repository, release): + self.context = context + self.owner = owner + self.repository = repository + self.release = release + + def candidate(self, release): + """Whether this is an older release that ships an apworld. + + Matches on asset filename alone: this runs over a repo's whole + history, and opening every zip in it would cost far more than + the problem is worth. + """ + cutoff = self.release.get("published_at") + published_at = release.get("published_at") + return bool( + not release.get("draft") + and release.get("tag_name") != self.release.get("tag_name") + and published_at + and (not cutoff or published_at < cutoff) + and any(asset["name"].lower().endswith(ApworldFile.SUFFIX) + for asset in release.get("assets", [])) + ) + + def older(self): + """The newest earlier release with an apworld, by date. + + Ordering comes from published_at rather than the order GitHub + returns, which follows the underlying tag and is not the same + thing. + """ + picker = ReleasePicker(self.context, self.owner, self.repository) + try: + releases = picker.all() + except requests.RequestException: + return None + candidates = [r for r in releases if self.candidate(r)] + if not candidates: + return None + return max(candidates, key=lambda release: release["published_at"]) + + def run(self, reason): + """The fallback's result, or None and a combined reason.""" + older = self.older() + if older is None: + return None, reason + fallback = ReleaseDownloader( + context=self.context, + release=older, + require_identity=True, + ).run() + if fallback[0] is not None: + fallback[2]["fallback_release_tag"] = older.get("tag_name") + return fallback, None + return None, (f"{reason}; older release {older.get('tag_name')!r} " + f"was tried too: {fallback[1]}") diff --git a/src/archipelago_tester/pipeline/download/zip_search.py b/src/archipelago_tester/pipeline/download/zip_search.py new file mode 100644 index 0000000..fb5fcd2 --- /dev/null +++ b/src/archipelago_tester/pipeline/download/zip_search.py @@ -0,0 +1,96 @@ +"""Looking for an apworld inside a release's zip assets.""" + +import os +import tempfile +import zipfile + +import requests + +from archipelago_tester.core.model.apworld import ApworldFile +from archipelago_tester.pipeline.download.local_file import LocalFile + + +class ZipSearch: + """For a release that publishes a zip instead of an apworld. + + Some repos attach a source snapshot with the built apworld nested + inside. Every zip is re-downloaded to scratch space each time, since + the member's own filename - what a normal download caches on - is + not known until the zip is opened. + """ + + #: The extension a release's archive asset is recognised by. + SUFFIX = ".zip" + + def __init__(self, context, release, headers=None): + self.context = context + self.release = release + self.headers = headers + + def zip_assets(self): + return [ + asset for asset in self.release.get("assets", []) + if asset["name"].lower().endswith(ZipSearch.SUFFIX) + ] + + def members(self, zip_path): + """Every apworld inside the zip, with its size.""" + try: + with zipfile.ZipFile(zip_path) as archive: + return [ + (info.filename, info.file_size) + for info in archive.infolist() + if not info.filename.endswith("/") + and info.filename.lower().endswith(ApworldFile.SUFFIX) + ] + except zipfile.BadZipFile: + return [] + + def extract(self, zip_path, member): + """Unpack one member into the game's folder.""" + target = self.context.target_path(os.path.basename(member)) + os.makedirs(os.path.dirname(target), exist_ok=True) + with zipfile.ZipFile(zip_path) as archive: + with archive.open(member) as source: + with open(target, "wb") as handle: + handle.write(source.read()) + return target + + def unpack(self, zip_path, members, zip_asset): + """The members as assets, already on disk.""" + results = [] + for member, size in members: + target = self.context.target_path(os.path.basename(member)) + status = LocalFile(self.context.session, target).status(size) + if status in LocalFile.NEEDS_DOWNLOAD: + self.extract(zip_path, member) + results.append({ + "name": os.path.basename(member), + "updated_at": zip_asset.get("updated_at"), + "created_at": zip_asset.get("created_at"), + "extracted_path": target, + "status": status, + }) + return results + + def search_one(self, zip_asset): + with tempfile.TemporaryDirectory() as scratch: + zip_path = os.path.join(scratch, zip_asset["name"]) + local = LocalFile(self.context.session, zip_path) + try: + local.download(zip_asset["browser_download_url"], + headers=self.headers) + except requests.RequestException: + return [] + members = self.members(zip_path) + if not members: + return [] + return self.unpack(zip_path, members, zip_asset) + + def assets(self): + """The first zip that holds an apworld wins.""" + for zip_asset in self.zip_assets(): + found = self.search_one(zip_asset) + if found: + return found + return [] diff --git a/src/archipelago_tester/pipeline/generation/__init__.py b/src/archipelago_tester/pipeline/generation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/archipelago_tester/pipeline/generation/command.py b/src/archipelago_tester/pipeline/generation/command.py new file mode 100644 index 0000000..2f4f7f2 --- /dev/null +++ b/src/archipelago_tester/pipeline/generation/command.py @@ -0,0 +1,102 @@ +"""Building the docker invocations the drivers run in.""" + +import os + +from archipelago_tester.core.model.apworld import ApworldFile + + +class ContainerCommand: + """The `docker run` for one driver, and the mounts it needs. + + The paths below are where this pipeline mounts things inside its + own container. Both ends of that contract live in this repository - + these mount arguments and the drivers that receive them - so they + are part of the code rather than something to configure. + """ + + #: Where each driver script is mounted. + DRIVER_PATH = "/app/run_test.py" + IDENTIFY_DRIVER_PATH = "/app/identify_apworld.py" + VERIFY_DRIVER_PATH = "/app/verify_companions.py" + + #: Where the apworld under test, the seeds and the ROMs go. + APWORLD_DIRECTORY = "/app/custom_worlds" + OUTPUT_DIRECTORY = "/data/output" + DATA_DIRECTORY = "/app/data" + + #: A writable cache directory. The image runs as uid 1000 but + #: leaves HOME as "/", which is root-owned, so anything resolving + #: "~/.cache" fails with PermissionError before generation starts. + CACHE_ENVIRONMENT = {"HOME": "/tmp", "XDG_CACHE_HOME": "/tmp/.cache"} + + def __init__(self, config, paths, image): + self.config = config + self.paths = paths + self.image = image + + def timeout(self, name, default): + return int(self.config.value("testing", name, default)) + + @property + def prefix(self): + """The part of every invocation that never varies.""" + return ["docker", "run", "--rm", "--network", "none", + "--entrypoint", "python", *self.cache_environment] + + @property + def cache_environment(self): + args = [] + for name, value in self.CACHE_ENVIRONMENT.items(): + args += ["-e", f"{name}={value}"] + return args + + @property + def common_client_mount(self): + """CommonClient.py, mounted back in from the checkout. + + The image excludes every client file, correctly - but a few + apworlds import their own during generation and fail with "No + module named 'CommonClient'" purely because it is absent. + Absent source means no mount: it exists only after a clone. + """ + if not os.path.isfile(self.paths.common_client): + return [] + return ["-v", f"{self.paths.common_client}:/app/CommonClient.py:ro"] + + @property + def rom_mounts(self): + """Every base ROM, mounted individually. + + Per file rather than mounting the directory over /app/data, + which would shadow the image's own shipped data. Worlds declare + rom_file inconsistently - some embed "data/", some do not - so + each is mounted at both locations. + """ + directory = self.paths.roms + if not os.path.isdir(directory): + return [] + data_in = self.DATA_DIRECTORY + args = [] + for name in sorted(os.listdir(directory)): + path = os.path.join(directory, name) + if os.path.isfile(path): + args += ["-v", f"{path}:{data_in}/{name}:ro"] + args += ["-v", f"{path}:/app/{name}:ro"] + return args + + def apworld_mount(self, apworld_path): + """The mount and container path for the world under test.""" + if not apworld_path: + return [], None + name = ApworldFile(apworld_path).importable_name + inside = f"{self.APWORLD_DIRECTORY}/{name}" + return ["-v", f"{apworld_path}:{inside}:ro"], inside + + def driver_mounts(self, output_dir): + """The driver, the output directory, the ROMs and the client.""" + return [ + "-v", f"{self.paths.driver}:{self.DRIVER_PATH}:ro", + "-v", f"{output_dir}:{self.OUTPUT_DIRECTORY}", + *self.rom_mounts, + *self.common_client_mount, + ] diff --git a/src/archipelago_tester/pipeline/generation/companion_check.py b/src/archipelago_tester/pipeline/generation/companion_check.py new file mode 100644 index 0000000..9f41fe1 --- /dev/null +++ b/src/archipelago_tester/pipeline/generation/companion_check.py @@ -0,0 +1,77 @@ +"""Which core worlds can actually generate in this container.""" + +import os +import subprocess + +from archipelago_tester.core.config.run_settings import RunSettings +from archipelago_tester.pipeline.generation.command import ContainerCommand +from archipelago_tester.pipeline.generation.report import DriverReport + + +class CompanionCheck: + """Tries every core world once, so the rest can trust the pool. + + Several core worlds need a base ROM this container has no copy of, + and drawing one fails the seed - with the failure landing on the + apworld under test rather than on the companion that caused it. + """ + + def __init__(self, config, paths, image): + self.config = config + self.paths = paths + self.image = image + self.command = ContainerCommand(config, paths, image) + + def build(self, output_dir, per_world_timeout, spoiler): + verify_in = ContainerCommand.VERIFY_DRIVER_PATH + return [ + *self.command.prefix, + "-v", f"{self.paths.verify_driver}:{verify_in}:ro", + "-v", (f"{self.paths.driver}:" + f"{ContainerCommand.DRIVER_PATH}:ro"), + "-v", (f"{output_dir}:" + f"{ContainerCommand.OUTPUT_DIRECTORY}"), + *self.command.rom_mounts, + *self.command.common_client_mount, + self.image, + verify_in, + "--output-dir", ContainerCommand.OUTPUT_DIRECTORY, + "--timeout", str(per_world_timeout), + "--spoiler", str(spoiler), + ] + + @staticmethod + def not_verified(detail): + return {"ran": False, "verified": [], "rejected": {}, + "detail": detail} + + def parse(self, result): + data = DriverReport(result).data + if data is not None: + data.setdefault("ran", True) + return data + return self.not_verified( + f"no JSON output (exit {result.returncode}): " + f"{DriverReport.tail(result.stderr)}") + + def run(self, output_dir, timeout=None, per_world_timeout=None, + spoiler=None): + """The pool of usable companions, or why there is none.""" + timeout = timeout or self.command.timeout("verify_timeout", 1800) + per_world_timeout = per_world_timeout or self.command.timeout( + "verify_per_world_timeout", 120) + if spoiler is None: + spoiler = RunSettings(self.config).spoiler + output_dir = os.path.abspath(output_dir) + os.makedirs(output_dir, exist_ok=True) + try: + result = subprocess.run( + self.build(output_dir, per_world_timeout, spoiler), + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return self.not_verified("companion verification did not " + f"finish within {timeout}s") + return self.parse(result) diff --git a/src/archipelago_tester/pipeline/generation/identifier.py b/src/archipelago_tester/pipeline/generation/identifier.py new file mode 100644 index 0000000..470d2cc --- /dev/null +++ b/src/archipelago_tester/pipeline/generation/identifier.py @@ -0,0 +1,70 @@ +"""Asking an apworld which game it registers as.""" + +import os +import subprocess +import tempfile + +from archipelago_tester.core.model.apworld import ApworldFile +from archipelago_tester.pipeline.generation.command import ContainerCommand +from archipelago_tester.pipeline.generation.report import DriverReport + + +class ApworldIdentifier: + """What a file's own World class says it is. + + Lighter than a test: no ROMs, no output volume, no generation. Used + right after download to check the content matches the row that + fetched it, before the file is trusted at all. + """ + + def __init__(self, config, paths, image): + self.paths = paths + self.image = image + self.command = ContainerCommand(config, paths, image) + + def build(self, apworld_path): + mount, inside = self.command.apworld_mount(apworld_path) + identify_in = ContainerCommand.IDENTIFY_DRIVER_PATH + return [ + *self.command.prefix, + *mount, + "-v", f"{self.paths.identify_driver}:{identify_in}:ro", + *self.command.common_client_mount, + self.image, + identify_in, + "--apworld", inside, + ] + + def failed(self, detail): + return {"ran": False, "games": [], "detail": detail} + + def identify(self, apworld_path, timeout=None): + """Which games this file registers, and whether the check ran.""" + timeout = timeout or self.command.timeout("identify_timeout", 60) + apworld_path = os.path.abspath(apworld_path) + if not os.path.isfile(apworld_path): + return self.failed(f"{apworld_path} does not exist (not a file)") + try: + result = self.run(apworld_path, timeout) + except subprocess.TimeoutExpired: + return self.failed( + f"identify check did not finish within {timeout}s") + data = DriverReport(result).data + if data is not None: + data.setdefault("ran", True) + return data + return self.failed( + f"no JSON output (exit {result.returncode}): " + f"{DriverReport.tail(result.stderr)}") + + def run(self, apworld_path, timeout): + """Run the driver against a prepared copy of the file. + + Repaired here as well as at test time, so a backslash-separated + world is not rejected at download for registering no game and + never reaching the test that could repair it. + """ + with tempfile.TemporaryDirectory(prefix="apworld-id-") as workdir: + checked, _ = ApworldFile(apworld_path).prepare(workdir) + return subprocess.run(self.build(checked), capture_output=True, + text=True, timeout=timeout) diff --git a/src/archipelago_tester/pipeline/generation/report.py b/src/archipelago_tester/pipeline/generation/report.py new file mode 100644 index 0000000..3b13c41 --- /dev/null +++ b/src/archipelago_tester/pipeline/generation/report.py @@ -0,0 +1,54 @@ +"""Reading what a driver reported.""" + +import json + + +class DriverReport: + """The JSON one driver run wrote to stdout.""" + + def __init__(self, result): + self.result = result + + @staticmethod + def tail(text, lines=20): + return "\n".join(text.splitlines()[-lines:]) + + @staticmethod + def last_json_object(text): + """The driver's report, however it ended up on the line. + + Not "the last line starting with {": worlds write to raw stdout + during generation, and one that writes without a trailing + newline leaves its output glued to the front of the report - + Super Metroid's randomizer emits a bare "*" per retry, so the + report arrives as "*{...}" and would read as no report at all. + """ + for line in reversed(text.splitlines()): + line = line.strip() + start = line.find("{") + while start != -1: + try: + return json.loads(line[start:]) + except json.JSONDecodeError: + start = line.find("{", start + 1) + return None + + @property + def data(self): + return self.last_json_object(self.result.stdout) + + def parsed(self): + """The report, or a failure built from what it printed.""" + report = self.data + if report is not None: + report.setdefault("stderr_tail", self.tail(self.result.stderr)) + return report + return { + "game": None, + "outcome": "failed", + "detail": ("no JSON report on stdout " + f"(exit code {self.result.returncode})"), + "elapsed_seconds": None, + "stdout_tail": self.tail(self.result.stdout), + "stderr_tail": self.tail(self.result.stderr), + } diff --git a/src/archipelago_tester/pipeline/generation/tester.py b/src/archipelago_tester/pipeline/generation/tester.py new file mode 100644 index 0000000..83a857e --- /dev/null +++ b/src/archipelago_tester/pipeline/generation/tester.py @@ -0,0 +1,132 @@ +"""Testing one world by generating seeds for it.""" + +import os +import subprocess +import tempfile + +from archipelago_tester.core.model.apworld import ApworldFile +from archipelago_tester.pipeline.generation.command import ContainerCommand +from archipelago_tester.pipeline.generation.report import DriverReport + + +class WorldTester: + """Runs every generation mode against one world.""" + + def __init__(self, config, paths, image): + self.config = config + self.paths = paths + self.image = image + self.command = ContainerCommand(config, paths, image) + + def settings(self, key, default): + return self.config.value("testing", key, default) + + def build(self, apworld_path, output_dir, timeout, companion_range, + spoiler, repeats, companion_pool, game, random_repeats): + """The full `docker run` for one world's whole test.""" + mount, inside = self.command.apworld_mount(apworld_path) + selector = ["--apworld", inside] if inside else ["--game", game] + return [ + *self.command.prefix, + *mount, + *self.command.driver_mounts(output_dir), + self.image, + ContainerCommand.DRIVER_PATH, + *selector, + "--output-dir", ContainerCommand.OUTPUT_DIRECTORY, + "--timeout", str(timeout), + "--companion-min", str(companion_range[0]), + "--companion-max", str(companion_range[1]), + "--spoiler", str(spoiler), + "--repeats", str(repeats), + "--random-repeats", str(random_repeats), + "--companion-pool", ",".join(companion_pool or ()), + ] + + def host_timeout(self, timeout, repeats, random_repeats): + """How long to let the whole container run. + + Every mode runs in the one container, each bounded by its own + timeout, so the host allows for all of them plus startup slack. + A ceiling rather than an expected cost. + """ + generations = 2 * repeats + 1 + 2 * random_repeats + buffer = self.settings("host_timeout_buffer", 30) + return timeout * generations + buffer + + def missing_file(self, path): + """The failure for a path that is not a file. + + Docker silently creates an empty directory at a missing mount + source rather than failing, which turns a not-yet-written + apworld into a bogus directory that breaks the content hash + days later with a confusing "Is a directory". + """ + return { + "game": None, + "outcome": "failed", + "detail": f"{path} does not exist (not a file)", + "elapsed_seconds": None, + } + + def test(self, apworld_path=None, game=None, output_dir=None, + timeout=None, companion_range=(2, 5), spoiler=3, repeats=10, + companion_pool=(), random_repeats=3): + """Run every mode against one world, core or downloaded.""" + output_dir = os.path.abspath(output_dir or self.paths.output) + os.makedirs(output_dir, exist_ok=True) + timeout = timeout or self.settings("generation_timeout", 300) + if apworld_path is not None: + apworld_path = os.path.abspath(apworld_path) + if not os.path.isfile(apworld_path): + return self.missing_file(apworld_path) + with tempfile.TemporaryDirectory(prefix="apworld-") as workdir: + tested, repaired = self.prepared(apworld_path, workdir) + report = self.run_once( + apworld_path=tested, + game=game, + output_dir=output_dir, + timeout=timeout, + companion_range=companion_range, + spoiler=spoiler, + repeats=repeats, + companion_pool=companion_pool, + random_repeats=random_repeats, + ) + if repaired: + report["repaired"] = True + return report + + def prepared(self, apworld_path, workdir): + """The path to test, and whether it had to be repaired.""" + if not apworld_path: + return None, False + return ApworldFile(apworld_path).prepare(workdir) + + def run_once(self, apworld_path, game, output_dir, timeout, + companion_range, spoiler, repeats, companion_pool, + random_repeats): + """One container run, and the report it produced.""" + limit = self.host_timeout(timeout, repeats, random_repeats) + command = self.build( + apworld_path=apworld_path, + output_dir=output_dir, + timeout=timeout, + companion_range=companion_range, + spoiler=spoiler, + repeats=repeats, + companion_pool=companion_pool, + game=game, + random_repeats=random_repeats, + ) + try: + result = subprocess.run(command, capture_output=True, text=True, + timeout=limit) + except subprocess.TimeoutExpired: + return { + "game": None, + "outcome": "failed", + "detail": f"docker run did not exit within {limit}s", + "elapsed_seconds": limit, + } + return DriverReport(result).parsed() diff --git a/src/archipelago_tester/pipeline/host/__init__.py b/src/archipelago_tester/pipeline/host/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/archipelago_tester/pipeline/host/custom_worlds.py b/src/archipelago_tester/pipeline/host/custom_worlds.py new file mode 100644 index 0000000..0ce4def --- /dev/null +++ b/src/archipelago_tester/pipeline/host/custom_worlds.py @@ -0,0 +1,134 @@ +"""Placing the approved apworlds where an Archipelago install finds them.""" + +import os +import shutil + +from archipelago_tester.core.model.apworld import ApworldFile +from archipelago_tester.core.model.verdict import Verdict +from archipelago_tester.core.state.keys import StateKeys + + +class CustomWorlds: + """The install's custom_worlds, kept to the worlds that passed. + + Only apworlds are placed. Nothing else in the install is read or + written - not host.yaml, not options, not the checkout - because the + install is someone else's and this pipeline only decides which + worlds are good enough to be in it. + + update_archipelago says whether to touch it at all and + archipelago_location says which install: with either missing, + nothing is changed and the reason is reported rather than guessed + at. + """ + + def __init__(self, config, paths, store, tag): + self.config = config + self.paths = paths + self.store = store + self.tag = tag + + def setting(self, key, default=None): + return self.config.value("archipelago", key, default) + + @property + def enabled(self): + return bool(self.setting("update_archipelago", False)) + + @property + def directory(self): + return self.paths.custom_worlds + + @property + def approved_labels(self): + """The labels good enough to place, matched case-insensitively. + + Written as the labels read rather than as the verdicts behind + them, so promote_labels and stability.labels are the same + wording and a rename in one is visible in the other. + """ + return {str(label).casefold() + for label in self.setting("promote_labels", [])} + + def approved(self, record): + """Whether this record's world belongs in the install. + + Judged on the verdict its own test produced, for the version + being run now: a result from an older Archipelago says nothing + about whether the world loads in this one. + """ + if record.get(StateKeys.DISCONTINUED): + return False + if record.get("tag") != self.tag: + return False + label = Verdict(record, self.config).label + return label.casefold() in self.approved_labels + + def candidates(self): + """Each approved apworld on disk, by the name it imports as. + + Two apworlds can declare the same importable name - a stray + download beside the real one - and one would silently overwrite + the other in a flat directory, so both are held back and + reported instead of picking a winner here. + """ + tests = self.store.load().get(StateKeys.APWORLD_TESTS, {}) + by_name = {} + for key, record in tests.items(): + if not self.approved(record): + continue + path = os.path.join(self.paths.downloads, key) + if os.path.exists(path): + name = ApworldFile(path).importable_name + by_name.setdefault(name, []).append(path) + placeable = {name: paths[0] for name, paths in by_name.items() + if len(paths) == 1} + collisions = {name: paths for name, paths in by_name.items() + if len(paths) > 1} + return placeable, collisions + + def place(self, placeable): + """Copy each approved apworld in under its importable name. + + Copied rather than linked: Archipelago loads these directly, + and a link into this pipeline's download cache would break the + moment that cache is cleared. + """ + os.makedirs(self.directory, exist_ok=True) + for name, path in placeable.items(): + shutil.copy2(path, os.path.join(self.directory, name)) + return sorted(placeable) + + def withdraw(self, placeable): + """Delete the apworlds that are no longer approved. + + A world that has stopped passing has to leave the install, not + merely stop being refreshed: leaving it there would keep + offering a world this pipeline no longer vouches for. Only + apworlds are ever removed, so anything else a person put in + that directory stays. + """ + present = {name for name in os.listdir(self.directory) + if name.endswith(ApworldFile.SUFFIX)} + withdrawn = present - set(placeable) + for name in withdrawn: + os.remove(os.path.join(self.directory, name)) + return sorted(withdrawn) + + def update(self): + """Bring the install's custom_worlds to the approved set.""" + if not self.enabled: + return {"updated": False, "reason": "update_archipelago is off"} + if not self.directory: + return {"updated": False, + "reason": "archipelago_location is not set"} + placeable, collisions = self.candidates() + placed = self.place(placeable) + withdrawn = self.withdraw(placeable) + return { + "updated": True, + "directory": self.directory, + "placed": placed, + "withdrawn": withdrawn, + "collisions": collisions, + } diff --git a/src/archipelago_tester/pipeline/sheet/__init__.py b/src/archipelago_tester/pipeline/sheet/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/archipelago_tester/pipeline/sheet/fetcher.py b/src/archipelago_tester/pipeline/sheet/fetcher.py new file mode 100644 index 0000000..037b006 --- /dev/null +++ b/src/archipelago_tester/pipeline/sheet/fetcher.py @@ -0,0 +1,62 @@ +"""Downloading the worlds sheet.""" + +import io +import os +import zipfile + +import requests + + +class SheetFetcher: + """The Playable Worlds sheet, as HTML.""" + + def __init__(self, config, paths): + self.config = config + self.paths = paths + + @property + def spreadsheet_id(self): + return self.config.value("sheet", "spreadsheet_id") + + @property + def tab_name(self): + return self.config.value("sheet", "tab_name") + + @property + def export_url(self): + return ("https://docs.google.com/spreadsheets/d/" + f"{self.spreadsheet_id}/export?format=zip") + + def fetch(self): + """The tab's HTML, as bytes. + + Google answers a private sheet with its sign-in page and status + 200 rather than an error, so the content type is checked instead + of the status. + """ + response = requests.get(self.export_url, timeout=120) + response.raise_for_status() + if "application/zip" not in response.headers.get("Content-Type", ""): + raise RuntimeError( + "The server did not return a zip archive. The sheet is " + "most likely not shared with 'anyone with the link'.") + with zipfile.ZipFile(io.BytesIO(response.content)) as archive: + wanted = f"{self.tab_name}.html" + if wanted not in archive.namelist(): + raise RuntimeError( + f"The export has no tab called '{self.tab_name}'. " + f"Available: {', '.join(archive.namelist())}") + return archive.read(wanted) + + def save(self, content=None): + """Write the tab's HTML, returning where it landed.""" + content = self.fetch() if content is None else content + with open(self.paths.sheet_html, "wb") as handle: + handle.write(content) + return self.paths.sheet_html + + def ensure(self): + """Download the sheet only when this machine has no copy.""" + if not os.path.exists(self.paths.sheet_html): + self.save() + return self.paths.sheet_html diff --git a/src/archipelago_tester/pipeline/sheet/reader.py b/src/archipelago_tester/pipeline/sheet/reader.py new file mode 100644 index 0000000..feb5420 --- /dev/null +++ b/src/archipelago_tester/pipeline/sheet/reader.py @@ -0,0 +1,98 @@ +"""Reading the worlds sheet into Game objects.""" + +from bs4 import BeautifulSoup + +from archipelago_tester.core.model.game import Game + + +class SheetReader: + """The sheet's HTML export, as rows.""" + + def __init__(self, config, path): + self.config = config + self.path = path + + def column(self, name): + return (self.config.value("sheet", "columns") or {})[name] + + def rows(self): + """Every table row, as lists of cells. + + Google repeats each row's frozen-column cells in a spacer, which + would shift every column index by one, so those are dropped. + """ + with open(self.path, "r", encoding="utf-8") as handle: + soup = BeautifulSoup(handle.read(), "html.parser") + found = [] + for row in soup.find_all("tr"): + cells = [ + cell for cell in row.find_all("td") + if "freezebar-cell" not in cell.get("class", []) + ] + if cells: + found.append(cells) + return found + + def header(self, rows): + """Where the header row is, and the headings it holds.""" + for index, cells in enumerate(rows): + texts = [cell.get_text(strip=True) for cell in cells] + if self.column("name") in texts: + return index, texts + raise ValueError( + f"No '{self.column('name')}' header found in the export.") + + def indexes(self, headers): + """Where each field sits in a row.""" + if self.column("link") not in headers: + raise ValueError(f"No '{self.column('link')}' column found.") + optional = {} + for field in ("stability", "pr_status"): + heading = self.column(field) + optional[field] = (headers.index(heading) + if heading in headers else None) + return (headers.index(self.column("name")), + headers.index(self.column("link")), + optional["stability"], + optional["pr_status"]) + + @staticmethod + def text_of(cells, index): + """One cell's text, or None when missing or empty.""" + if index is None or index >= len(cells): + return None + return cells[index].get_text(strip=True) or None + + @staticmethod + def links_in(cell): + """Every hyperlink target in one cell, in the order written.""" + return [anchor.get("href") for anchor in cell.find_all("a") + if anchor.get("href")] + + def build(self, cells, indexes): + """One row as a Game, or None when it has no name.""" + name_index, link_index, stability_index, status_index = indexes + name = self.text_of(cells, name_index) + if name is None: + return None + game = Game( + name=name, + stability=self.text_of(cells, stability_index), + pr_status=self.text_of(cells, status_index), + ) + if link_index < len(cells): + for link in self.links_in(cells[link_index]): + game.add_link(link) + return game + + def games(self): + """Every world listed in the export.""" + rows = self.rows() + header_index, headers = self.header(rows) + indexes = self.indexes(headers) + found = [] + for cells in rows[header_index + 1:]: + game = self.build(cells, indexes) + if game is not None: + found.append(game) + return found diff --git a/src/archipelago_tester/pipeline/update/__init__.py b/src/archipelago_tester/pipeline/update/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/archipelago_tester/pipeline/update/cli.py b/src/archipelago_tester/pipeline/update/cli.py new file mode 100644 index 0000000..e3e2617 --- /dev/null +++ b/src/archipelago_tester/pipeline/update/cli.py @@ -0,0 +1,68 @@ +"""The command line for a full update pass.""" + +import argparse +import json + +from archipelago_tester.core.config.config import Config +from archipelago_tester.core.config.paths import Paths +from archipelago_tester.core.config.run_settings import RunSettings +from archipelago_tester.core.state.lock import LockHeldError, PipelineLock +from archipelago_tester.pipeline.update.stage_error import StageError +from archipelago_tester.pipeline.update.update_run import UpdateRun + + +class UpdateCli: + """Update Archipelago, the sheet and every apworld, then test.""" + + def __init__(self): + self.config = Config.load() + self.paths = Paths(self.config) + self.settings = RunSettings(self.config) + + def parser(self): + parser = argparse.ArgumentParser(description=self.__doc__) + parser.add_argument( + "--stability", + help="Only consider games with this Stability column value " + "(e.g. 'stable', 'broken on main'). Case-insensitive.") + parser.add_argument( + "--fetch", type=int, + help="Only consider the first N games, after --stability " + "filtering - for a cheap, bounded pass.") + parser.add_argument( + "--jobs", "-j", type=int, default=None, + help="Apworlds to generation-test at once " + f"(default: {self.settings.jobs}).") + parser.add_argument( + "--force-retest", action="store_true", + help="Re-test every apworld, ignoring the cache. Expensive.") + return parser + + def run(self): + args = self.parser().parse_args() + run = UpdateRun( + config=self.config, + paths=self.paths, + stability=args.stability, + fetch=args.fetch, + jobs=args.jobs, + force_retest=args.force_retest, + ) + try: + with PipelineLock(self.paths.lock): + report = run.run() + except LockHeldError as error: + print(error) + return 1 + except StageError as error: + print(f"Update {error}") + return 1 + print("\n=== summary ===") + summary = {key: value for key, value in report.items() + if key != "test_results"} + print(json.dumps(summary, indent=2, default=str)) + return 0 + + @classmethod + def main(cls): + return cls().run() diff --git a/src/archipelago_tester/pipeline/update/download_reporter.py b/src/archipelago_tester/pipeline/update/download_reporter.py new file mode 100644 index 0000000..dac9a92 --- /dev/null +++ b/src/archipelago_tester/pipeline/update/download_reporter.py @@ -0,0 +1,37 @@ +"""The download stage's progress line.""" + +import os + +from archipelago_tester.core.display.progress import Progress + + +class DownloadReporter: + """One line per game, saying what its apworld did. + + The download run calls back serialized, so this needs no lock of its + own. What each game's last asset did is remembered so the line can + still name it once the game itself finishes. + """ + + #: What each download status means, in words. + STATUS_LABELS = { + "missing": "downloaded (new)", + "stale": "downloaded (updated)", + "verified": "already present (verified up to date)", + "assumed": "already present (not verified)", + } + + def __init__(self, config, total): + self.bar = Progress(total, config=config, + labels={"ok": "ok", "none": "none"}) + self.last_asset = {} + + def on_asset(self, game_name, path, status): + label = self.STATUS_LABELS.get(status, status) + self.last_asset[game_name] = f"{label} {os.path.basename(path)}" + + def on_game(self, index, total, game_name, downloaded): + detail = self.last_asset.pop(game_name, "no apworld") + self.bar.update( + index, "ok" if downloaded else "none", game_name, + fallback_line=f"[{index}/{total}] {game_name}: {detail}") diff --git a/src/archipelago_tester/pipeline/update/stage_error.py b/src/archipelago_tester/pipeline/update/stage_error.py new file mode 100644 index 0000000..8749153 --- /dev/null +++ b/src/archipelago_tester/pipeline/update/stage_error.py @@ -0,0 +1,15 @@ +"""A failure, named by the stage it happened in.""" + + +class StageError(RuntimeError): + """Which stage failed, so the run can say so and stop. + + Raised at the stage boundary rather than inside it: a stage can fail + anywhere after it was opened, and an unnamed traceback makes a long + run's failure much harder to place. + """ + + def __init__(self, stage, original): + super().__init__(f"failed during '{stage}': {original}") + self.stage = stage + self.original = original diff --git a/src/archipelago_tester/pipeline/update/update_run.py b/src/archipelago_tester/pipeline/update/update_run.py new file mode 100644 index 0000000..afd1cdb --- /dev/null +++ b/src/archipelago_tester/pipeline/update/update_run.py @@ -0,0 +1,298 @@ +"""Everything the testing half does in one pass. + +Update Archipelago to the newest release, refresh the worlds sheet, +download whatever changed, and test each world by generating seeds. +What happens to the results afterwards - promoting, publishing, +deploying - is the other half's job and is not known about here. +""" + +import contextlib + +import requests + +from archipelago_tester.core.config.run_settings import RunSettings +from archipelago_tester.core.config.secrets import Secrets +from archipelago_tester.core.model.name import Name +from archipelago_tester.core.state.keys import StateKeys +from archipelago_tester.core.state.store import StateStore +from archipelago_tester.pipeline.batch.batch import Batch +from archipelago_tester.pipeline.batch.discontinued import Discontinued +from archipelago_tester.pipeline.batch.orphaned_records import ( + OrphanedRecords, +) +from archipelago_tester.pipeline.batch.download_failures import ( + DownloadFailures, +) +from archipelago_tester.pipeline.batch.reporter import Reporter +from archipelago_tester.pipeline.batch.results import Results +from archipelago_tester.pipeline.build.archipelago import ArchipelagoBuild +from archipelago_tester.pipeline.download.download_run import DownloadRun +from archipelago_tester.pipeline.download.update_history import ( + UpdateHistory, +) +from archipelago_tester.pipeline.generation.identifier import ApworldIdentifier +from archipelago_tester.pipeline.host.custom_worlds import CustomWorlds +from archipelago_tester.pipeline.sheet.fetcher import SheetFetcher +from archipelago_tester.pipeline.sheet.reader import SheetReader +from archipelago_tester.pipeline.update.download_reporter import ( + DownloadReporter, +) +from archipelago_tester.pipeline.update.stage_error import StageError + + +class UpdateRun: + """The four stages, in the order they run. + + Takes no lock of its own: a caller that also publishes the results + holds one for the whole thing, so nothing swaps underneath a + generation still in progress. + """ + + def __init__(self, config, paths, stability=None, fetch=None, jobs=None, + force_retest=False): + self.config = config + self.paths = paths + self.settings = RunSettings(config) + self.store = StateStore(paths.state) + self.build = ArchipelagoBuild(config, paths) + self.stability = stability + self.fetch = fetch + self.jobs = jobs or self.settings.jobs + self.force_retest = force_retest + self.report = {} + + @contextlib.contextmanager + def stage(self, name): + print(f"\n=== {name} ===") + try: + yield + except Exception as error: + raise StageError(name, error) from error + + def snapshot_previous_version(self, previous_tag): + """Keep last version's results before this one overwrites them. + + The only source for "did this world's outcome change at the + exact moment Archipelago upgraded": it is the state at the + instant the version changed, which no later re-test can + reconstruct. + """ + state = self.store.load() + state[StateKeys.UPGRADE_SNAPSHOT] = dict( + state.get(StateKeys.APWORLD_TESTS, {})) + state[StateKeys.PREVIOUS_ARCHIPELAGO_TAG] = previous_tag + self.store.save(state) + + def update_archipelago(self): + """Put this machine on the newest release and build its image.""" + with self.stage("archipelago_build"): + tag, previous, changed, built = self.build.update_and_build( + self.store) + self.build.ensure_test_image(tag) + self.report.update({ + "archipelago_tag": tag, + "archipelago_previous_tag": previous, + "archipelago_tag_changed": changed, + "archipelago_image_built": built, + }) + print(f"tag {tag}" + (f" (was {previous})" if previous + else " (first run)")) + if changed and previous: + self.snapshot_previous_version(previous) + return tag, changed + + def announce_retired(self, retired): + gone = sorted(key for key, value in retired.items() if value) + back = sorted(key for key, value in retired.items() if not value) + if gone: + print(f"{len(gone)} no longer on the sheet, marked " + f"discontinued: {', '.join(gone)}") + if back: + print(f"{len(back)} back on the sheet: {', '.join(back)}") + + def select(self, games): + """The rows this pass considers. + + Applied to the whole sheet, core rows included, so --fetch + really does bound the work: core worlds are tested like every + other row, and leaving them out of the limit made a "cheap, + bounded pass" still test all eighty of them. + """ + if self.stability: + games = [game for game in games + if (game.stability or "").lower() + == self.stability.lower()] + return games[:self.fetch] if self.fetch is not None else games + + def read_sheet(self): + """The sheet's rows, split into core and downloadable. + + Discontinued rows are flagged straight after reading, so every + stage below already sees a world taken off the sheet as gone. + Core rows are tested by name but never downloaded: Archipelago + ships them. + """ + with self.stage("sheet_download"): + path = SheetFetcher(self.config, self.paths).save() + games = SheetReader(self.config, path).games() + # Every row, before any filtering: download-time identity + # checks compare against the whole sheet, core rows + # included, and so does the discontinued flag. + sheet_names = [game.name for game in games] + self.report["games_in_sheet"] = len(games) + print(f"{len(games)} games in sheet") + retired = Discontinued(self.store).mark(sheet_names) + self.report["discontinued_changed"] = retired + self.announce_retired(retired) + selected = self.select(games) + core = [game for game in selected if game.is_core] + rest = [game for game in selected if not game.is_core] + self.report["games_core"] = len(core) + self.report["games_selected"] = len(rest) + print(f"{len(core)} core games (tested by name, nothing to download)") + print(f"{len(rest)} games selected (stability={self.stability!r}, " + f"fetch={self.fetch!r})") + return rest, core, sheet_names + + def session(self): + """A session carrying the token, if there is one.""" + session = requests.Session() + token = Secrets(self.paths).github_token + if token: + session.headers["Authorization"] = f"token {token}" + return session + + def downloader(self, image, sheet_names): + return DownloadRun( + config=self.config, + paths=self.paths, + session=self.session(), + identifier=ApworldIdentifier(self.config, self.paths, image), + jobs=self.jobs, + sheet_names=sheet_names, + # Last run's resolved releases, so a game whose attempt + # failed against an unchanged release is not downloaded and + # re-identified all over again. + previous_releases=self.store.load().get( + StateKeys.GAME_RELEASES, {}), + ) + + def download(self, games, sheet_names, image, tag): + """Fetch every selected game's newest apworld.""" + reporter = DownloadReporter(self.config, len(games)) + run = self.downloader(image, sheet_names) + with self.stage("apworld_download"): + total, skipped, releases = run.all( + games, on_asset=reporter.on_asset, on_game=reporter.on_game) + self.report["assets_downloaded"] = total + self.report["games_skipped_download"] = len(skipped) + print(f"{total} assets downloaded, {len(skipped)} games skipped") + run.record_releases(releases) + self.record_versions() + DownloadFailures(self.config, self.store).record(games, skipped, tag) + orphaned = OrphanedRecords(self.store, self.paths.downloads).prune() + self.report["orphaned_records_dropped"] = len(orphaned) + if orphaned: + print(f"{len(orphaned)} result(s) dropped for apworlds no " + f"longer on disk: {', '.join(sorted(orphaned))}") + + def record_versions(self): + """One history entry per game whose apworld content changed. + + Recorded here, not by whatever publishes afterwards: this is + the moment a new version is known, and a pass that never + publishes would lose it outright - the next download overwrites + the file and no later stage can reconstruct which version + existed on which date. + """ + history = UpdateHistory(self.paths) + with self.stage("apworld_history"): + recorded = history.record_all(self.store.load(), + self.paths.downloads) + self.report["new_apworld_versions"] = len(recorded) + print(f"{len(recorded)} new apworld version(s) recorded") + + def selected_only(self, games, core_games): + """This pass's rows, named the way the batch names them. + + None unless something actually narrowed the sheet. The download + directory holds every apworld ever fetched, so an unnarrowed run + has to consider all of them - and Batch reads an explicit + selection as a reason to retest rather than serve from cache, + which would turn a plain pass into a full re-test of everything. + + Downloadable rows are matched on their download folder, core + ones on the bare sheet name, because that is how each is keyed. + """ + if self.fetch is None and not self.stability: + return None + return ([Name(game.name).directory for game in games] + + [game.name for game in core_games]) + + def test(self, image, tag, games, core_games, changed): + """Generation-test the downloaded apworlds, and the core ones. + + Core worlds ship with Archipelago, so nothing was downloaded for + them and they are named rather than mounted, but they run the + same five modes and their result is measured rather than + assumed. + + A narrowed pass tests what it narrowed to and nothing else: + --fetch bounded the download stage from the start, and leaving + the test stage unbounded meant a "cheap, bounded pass" still + generated against every apworld on disk. + """ + only = self.selected_only(games, core_games) + if only is not None: + print(f"{len(only)} selected for testing " + f"(stability={self.stability!r}, fetch={self.fetch!r})") + with self.stage("generation_tests"): + results = Batch( + config=self.config, + paths=self.paths, + settings=self.settings, + image=image, + tag=tag, + force=changed or self.force_retest, + on_progress=Reporter(self.config, bar=True), + jobs=self.jobs, + core_games=[game.name for game in core_games], + only=only, + exact_only=only is not None, + ).run() + summary = Results(results, self.config).summary + self.report["test_counts"] = summary + self.report["test_results"] = results + print(summary) + return results + + def place_apworlds(self, tag): + """Put the worlds that passed into the Archipelago install. + + Last, because it acts on what the tests just found. Off by + default: an install is someone's own, so it is only written to + when update_archipelago says so, and then only its + custom_worlds directory. + """ + worlds = CustomWorlds(self.config, self.paths, self.store, tag) + with self.stage("custom_worlds"): + result = worlds.update() + self.report["custom_worlds"] = result + if not result["updated"]: + print(f"not updating any install - {result['reason']}") + return + print(f"{len(result['placed'])} apworlds in {result['directory']}, " + f"{len(result['withdrawn'])} withdrawn") + if result["collisions"]: + print(f"{len(result['collisions'])} name collision(s) held " + f"back: {', '.join(sorted(result['collisions']))}") + + def run(self): + """Every stage, in order, returning what each one did.""" + tag, changed = self.update_archipelago() + image = self.build.test_image_tag(tag) + games, core_games, sheet_names = self.read_sheet() + self.download(games, sheet_names, image, tag) + self.test(image, tag, games, core_games, changed) + self.place_apworlds(tag) + return self.report diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..1be2113 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2048 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.11' and python_full_version < '3.14'", + "python_full_version < '3.11'", +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "appnope" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/f7/a82489c2b6ebe32d3e2831895ae19c77861f0eadf3bb16034484d965dbb2/appnope-1.0.0.tar.gz", hash = "sha256:685db59cb6043c3c2e528adc0b3bce3a5f8d09bcf7492c6ea650d1b7421f3c49", size = 5454, upload-time = "2026-08-20T21:36:13.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/c7/6b687cb0f83d2a51017d47953f2ee430ffad6fec2a8ebc964e0718a33eb1/appnope-1.0.0-py3-none-any.whl", hash = "sha256:6fe0c04218aab65c54c4ff81638cdbf848d89f5653b74d68638a137f200dd16e", size = 4158, upload-time = "2026-08-20T21:36:12.444Z" }, +] + +[[package]] +name = "archipelago-tester" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "pyyaml" }, + { name = "requests" }, +] + +[package.dev-dependencies] +notebook = [ + { name = "ipykernel" }, + { name = "jupyterlab" }, +] + +[package.metadata] +requires-dist = [ + { name = "beautifulsoup4" }, + { name = "pyyaml" }, + { name = "requests" }, +] + +[package.metadata.requires-dev] +notebook = [ + { name = "ipykernel" }, + { name = "jupyterlab" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" }, + { url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" }, + { url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5f/f652055e18d2627e2eed94c7f31a792127cfe38df786635395d742321674/argon2_cffi_bindings-26.1.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:af11ac37a7c53dc16cb7950a6190851b0870fe218b6c60c0bb7ac355234e3083", size = 15434, upload-time = "2026-08-20T07:32:53.143Z" }, + { url = "https://files.pythonhosted.org/packages/76/38/de696045960f5b846d428c0fb6c130ed3da87aac2af209b05c193815404c/argon2_cffi_bindings-26.1.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:db0fcd827ca61622a01b220aadfbece01939acf53888f2cb98cd93e9b1e2c97e", size = 15449, upload-time = "2026-08-20T07:32:54.075Z" }, + { url = "https://files.pythonhosted.org/packages/91/0a/c25af768f6b75a5a71e31207f87c540656b2808c015260444a22763221ad/argon2_cffi_bindings-26.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:28524438cd3e723f25412f63d4fd516ff5bae9ae5aa56acbe2a1404398a0cf31", size = 25683, upload-time = "2026-08-20T07:32:55.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7e/be212c751ab0bcea7f646615f933bf262e8e50b3f7bef32f861d0a2d066b/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac82fc756a446b6ccd7139ce70efa9d8bbe541e7ad579a12dcb52764b7175c5f", size = 27311, upload-time = "2026-08-20T07:32:56.166Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ee/f84b28e4afd13d3cac36c1d8fa8c239d2dc2c51cd978d02ee5d5ad98d9bb/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a4e68eed961a8de6928d1c17ff3dc2a547e0e923c17f8f1cd79fb7bc9502f98", size = 26771, upload-time = "2026-08-20T07:32:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/21/c3/95c07a023691ecd529da9cb6a8f0779e13ebc1bdfaa86d145fdc1c6e7e79/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:151dfaad9de753f4af2a7854e707e4784f2acc434340ade64239c5b104b2d605", size = 27568, upload-time = "2026-08-20T07:32:58.361Z" }, + { url = "https://files.pythonhosted.org/packages/e6/31/3a18e31406d8694b4d6a31573c3e572fff6bed318bb744453eb653766d22/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:061a6919145bbf282ebf1f9c59d3135d4833c25313c8595c0d68cf7712ddfce2", size = 27280, upload-time = "2026-08-20T07:32:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/0b/39/d4be4577e178b2397aa5b5575c8a309bf0da2afe05fe0c72c8f398662d63/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:62ff20cd130c956c7c9144d5fe35228f98b51c579b2439e988b27ef93e16c02a", size = 27776, upload-time = "2026-08-20T07:33:00.325Z" }, + { url = "https://files.pythonhosted.org/packages/71/47/78f4dd96f7411339f723b96fe24039c1bd5835102b8a5ba71ac4ec712ac7/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:19423e5d7ac1cc354baab59eaabf18db2ec04ef6593b5abe5a34f323c4a8f87a", size = 26932, upload-time = "2026-08-20T07:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/96bfd37434cc0a848a9066c291d84b28846c4c9ea289ed9866b1164d622b/argon2_cffi_bindings-26.1.0-cp314-cp314t-win32.whl", hash = "sha256:4f84cdd868978d7b7350a566c254042d44216d9e37f241f3a6d3b1dfebeede35", size = 24878, upload-time = "2026-08-20T07:33:02.189Z" }, + { url = "https://files.pythonhosted.org/packages/f1/42/d8b6810abd9b1bd2f47ebbccf460da59c9f32e94888bea4f7b137d998797/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2b741888c93147444fdfc851abd81cc207f37f7f7da42062a00deb3888e57da8", size = 26656, upload-time = "2026-08-20T07:33:03.222Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d1/095d95eaf2ed1d9f77268cf3291bde148c6cd56121f8db2c74c1ba618a0e/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1", size = 25378, upload-time = "2026-08-20T07:33:04.332Z" }, + { url = "https://files.pythonhosted.org/packages/66/cb/214092c39c4dbcb72cf98b12234ddac2221f8fe2c0acf29c6a70fa83be53/argon2_cffi_bindings-26.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1d98e33bd8bd67d7206c124e200bf2229c4cfa8c9c19f7b44a897f0fc71837eb", size = 25683, upload-time = "2026-08-20T07:33:05.337Z" }, + { url = "https://files.pythonhosted.org/packages/83/e5/02015b83e9b05ccb85ff2ced424cf6e83a12d3810bc7f66d679a92b69ffb/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccaf0a46cbb380f1fd102a874e32aa629fd3cb0c0e94f4943fa1f6d5edc5dac6", size = 27310, upload-time = "2026-08-20T07:33:06.344Z" }, + { url = "https://files.pythonhosted.org/packages/c3/4a/85e612787d0796878b3b4f6bd53dcd5484b6fe7b64cc6fc7b6e6a04cf835/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0c3103fcff20183e593459cfea6e012281c0e76ae3ed8b5565ad1b92eac3990", size = 26771, upload-time = "2026-08-20T07:33:07.429Z" }, + { url = "https://files.pythonhosted.org/packages/f6/84/ccb003b6f9969820e87656398f4d49c857def71a85ca1588a0e809afd7ce/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c49e853a3bef9dd10329f31f702e7fa9b5c58229ff9c2ff6d069efaf09177c08", size = 27569, upload-time = "2026-08-20T07:33:08.598Z" }, + { url = "https://files.pythonhosted.org/packages/88/07/c26b76debf0998ee08fbe947ab2058ac5de37d4b9d46b06c17abaa6c4ce9/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6376d4b3aca039375ca8bf92f770da0ec424a1ce3a37077a8d3c557411aa56ca", size = 27279, upload-time = "2026-08-20T07:33:09.518Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0d/ead6ddc029f91bc9b9390686dad3c808ab08100d348f6266b5f93f8970ee/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:9bacedc04b0402837586a17f0919e3dfdd95291f441f1f56bd80ec274c2840a1", size = 27774, upload-time = "2026-08-20T07:33:10.728Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/c108530d9eb86036b78d3af4de28b83b4a2d9a70512bd10ff8e59966aab4/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:76ae29acace5d33355344612844d588e19deaaba4639d8bb01601e4b1418ef36", size = 26933, upload-time = "2026-08-20T07:33:11.661Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/0bfc59e781c89acf64c31c388aade9d9d1c1ea38aa1ba1292fe07f607fe9/argon2_cffi_bindings-26.1.0-cp315-cp315t-win32.whl", hash = "sha256:df612391feca41c44d20118f3b88d1b86419465cd1f5496859f715ca60ec2210", size = 24875, upload-time = "2026-08-20T07:33:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/61/c7/c3e46068cddffccecb8ad94d71135e9bf62bbc789589e7dfadc7c6f59214/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1a0a29ed86960e44eaace7e081bdfab4f08b012fd96ec8edba71e2ad020939e4", size = 26655, upload-time = "2026-08-20T07:33:13.521Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ca/18b9c8c45fecf34b9100ec6d7946057f14a158f2eaa20ea123a3e82351cb/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d157ddfab1e8b21f2f1dedda9c09645d98b5ed0b667b0626be600a345d426440", size = 25376, upload-time = "2026-08-20T07:33:14.491Z" }, + { url = "https://files.pythonhosted.org/packages/94/66/7ff138b7a61a6ec4eb8ad4a98696498915492a5ffa190e937ca5f2827e0a/argon2_cffi_bindings-26.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:7014ab7e6f5d8511af92544667a0346ea6dfc314ea9a7cad1dba9fdb5c9a6e33", size = 23059, upload-time = "2026-08-20T07:33:15.45Z" }, + { url = "https://files.pythonhosted.org/packages/de/6d/f120f8b4882da540b5e1375a11c85cfe37b3c671cfae1cdac797ea23e76b/argon2_cffi_bindings-26.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:242bb0cda2ae3650764fc194593d9ea45fc9e72729acd89778c7cfe184cec2a5", size = 24869, upload-time = "2026-08-20T07:33:16.528Z" }, + { url = "https://files.pythonhosted.org/packages/04/50/92811103e1042af1379741db7fd4a6d0f6e4ee4512e2c50cbd0d344cf0d8/argon2_cffi_bindings-26.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b70225b5fd1e0d2ef4f7fd30d24658454535f0924dff0caca5dc08efbbbadfbb", size = 24219, upload-time = "2026-08-20T07:33:17.617Z" }, + { url = "https://files.pythonhosted.org/packages/47/f2/1f8548c44c0036ae8ac1d6197300570b0af8ec120fc10b5bd8188f507376/argon2_cffi_bindings-26.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1af817e84578ef8b7295ad17de0f9896e4c8520dbf2233c7aa5aa3d487256fc4", size = 23589, upload-time = "2026-08-20T07:33:18.594Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b9/97f0370f99611b14efd384918613dd5cbda75f28d9bb1b677aacfeaa17df/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:19b562b1de4b9052ef1214a2821c44b6e6f22945daa102c32ae4eff929d8b6d8", size = 23055, upload-time = "2026-08-20T07:33:19.716Z" }, + { url = "https://files.pythonhosted.org/packages/ae/70/7eb3fe7bf00103cbbb569c51aef150661f22b734a782673a600ff0f52309/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d525938467d52c923a890153c99087c9d5a937d1f6b585dbdba34ec82e397a", size = 24869, upload-time = "2026-08-20T07:33:20.671Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4b/9d5919c6cb1f15df7406af0f99b048bd93936f112e3e8f4c8077bc2a9110/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b0bcac4d490a237e18cf91f57352920c29f77f2fa39efd0813fb81298bf17ba", size = 24216, upload-time = "2026-08-20T07:33:21.653Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/32109943bace7729233cc4ee78530baa306d8cc3c6501a64ba8cb3b58129/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0cc40f7b4050bb93eb67de95d2d759322fc7ce4930b9d645581ecf4913ec651e", size = 23584, upload-time = "2026-08-20T07:33:22.613Z" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, +] + +[[package]] +name = "async-lru" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "bleach" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" }, + { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" }, + { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, + { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, + { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" }, + { url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" }, + { url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" }, + { url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f3/6b1d4c71f4cbb5360009f928934a03b42906f28fc7b3f7f35f04e58acead/debugpy-1.8.21-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:8eeab7b5462f683452c57c0126aaa5ec4e974ddb705f39ba87dff8818c8e08f9", size = 2113873, upload-time = "2026-06-01T19:30:37.148Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f2/17c3bf91cebc173bfbf5734cd2669723d0a35c0cf9d2fd2124546efeae83/debugpy-1.8.21-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:0fddfdc130ac6d8bfc0415b0409822fa901c8f310e5c945ac5653a0352532344", size = 3004715, upload-time = "2026-06-01T19:30:38.888Z" }, + { url = "https://files.pythonhosted.org/packages/5a/22/1f8efd80c7b5909e760f9cfd0c9e8681d2d35d532f7c0a40760cd4da4a19/debugpy-1.8.21-cp310-cp310-win32.whl", hash = "sha256:72b5d676c4cbfac3bac5bb01c138a4656e843f93f03ce2a5f4e394ad49fbee73", size = 5303455, upload-time = "2026-06-01T19:30:40.52Z" }, + { url = "https://files.pythonhosted.org/packages/da/ce/54c79abd6cccef92fa7b43d97e3acafedf4d645557267ece05e948b5e4b8/debugpy-1.8.21-cp310-cp310-win_amd64.whl", hash = "sha256:a7fe47fd23da57b9e0bec3f4a8ee65a2dc55782455ed7f2141d75ab5d2eaeef5", size = 5331751, upload-time = "2026-06-01T19:30:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/cbf306d6e07a313a91e7171a98669054502840931432c227cfd505ee367f/debugpy-1.8.21-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:da456226c7b4c69e35dbe35dcee6623d912000a77816db7856a41af1c72a0264", size = 2203120, upload-time = "2026-06-01T19:30:43.964Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/aa739bd4ad2cbf96aeb1b20b56918ddd5ae4c28b68709bfcd327f02123ee/debugpy-1.8.21-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:f68b891688e61bdc08b8d364d919ff0051e0b94657b39dcd027bc3173edb7cdc", size = 3059958, upload-time = "2026-06-01T19:30:45.622Z" }, + { url = "https://files.pythonhosted.org/packages/a8/31/453d2c9a23d133fe2c8ec7ca1d816ded52a913487fe3ffef7c01b4b706af/debugpy-1.8.21-cp311-cp311-win32.whl", hash = "sha256:f843a8b08c2edeaf9b1582eed4f25441af21a297c22ff16bf76a662557aa9c9e", size = 5236515, upload-time = "2026-06-01T19:30:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/6660de2f2d7bf388f229335ba4637646eebabdbf38564cb439a95a9193c9/debugpy-1.8.21-cp311-cp311-win_amd64.whl", hash = "sha256:84c564d8cc701d41843b29a92814c1f1bef6798724ca9d675c284ad9f6a547d7", size = 5256138, upload-time = "2026-06-01T19:30:49.113Z" }, + { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" }, + { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" }, + { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" }, + { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" }, + { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" }, + { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.22.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/a4/9473c7c3b87009d9c1d74034e4a0f6a35ff0d42dd0f9866d0c3ec4e9217b/fastjsonschema-2.22.2.tar.gz", hash = "sha256:72064e12356a7d6ef02165be2946b9abadbdf238536e07eb587e3dbaa33099cf", size = 385171, upload-time = "2026-08-15T19:47:08.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/82/2755c7c982086f00d4dab85bc120ec35045a9fc2191893a6ce79afe94443/fastjsonschema-2.22.2-py3-none-any.whl", hash = "sha256:0fb3915616adac85ccfdd737d26be1089845d2019819505b42d39888458f74d4", size = 27413, upload-time = "2026-08-15T19:47:04.406Z" }, +] + +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "ipykernel" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio2" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" }, +] + +[[package]] +name = "ipython" +version = "8.39.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "exceptiongroup" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" }, +] + +[[package]] +name = "ipython" +version = "9.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.11' and python_full_version < '3.14'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/32/99451b1283ec5d92ad77073f12e1c667dc10775384d8f15c2914207149dd/ipython-9.17.1.tar.gz", hash = "sha256:8919be8c27f20a6f4423145028063f6637b42a03ce57665bb12015ee1f073529", size = 4539289, upload-time = "2026-09-01T08:29:32.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/1e/65b59cf518c106aa755e7f7da3099027738687a862ec785060702a481320/ipython-9.17.1-py3-none-any.whl", hash = "sha256:6d1645743cfd1a07eb695d85aa2b5fa66721f8cbae9431d4049f7084bbf06509", size = 639038, upload-time = "2026-09-01T08:29:30.673Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "json5" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/7d/05c46a96a78147ae3bf99c2f4169ce144a70220b8d6fcd56f6ec368b8ce9/json5-0.15.0.tar.gz", hash = "sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71", size = 53278, upload-time = "2026-06-19T20:08:27.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/be/59527c99478aade6bb33a68d72e6e18dd4e6ff6eacfc7d01bdb15bc76912/json5-0.15.0-py3-none-any.whl", hash = "sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618", size = 36570, upload-time = "2026-06-19T20:08:26.748Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[package.optional-dependencies] +format-nongpl = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "rfc3987-syntax" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter-builder" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/3e/56f593e6a664cd14d441724654ce8243f3cac7d3e45867cf084749c8bc75/jupyter_builder-1.2.3.tar.gz", hash = "sha256:01aba6794eb9b19e0e29ae21137ca60ba4135c70347d4b9f664a586822b8c809", size = 1024218, upload-time = "2026-09-04T18:54:09.411Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/aa/be79e87c50698673f196d0633fc1664607da2289f9688d1de7a1c9db9e71/jupyter_builder-1.2.3-py3-none-any.whl", hash = "sha256:c5ea5a7190c2a7b082494abade98eece1b2b5bd5dbd7d610606cbcd10a1d08b3", size = 947541, upload-time = "2026-09-04T18:54:07.644Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/2a/906772148a06e48885039e0250c340b770a55bbf37b08d6ee0449df369c3/jupyter_client-8.10.0.tar.gz", hash = "sha256:9f7116294dca55f1785be880057d44544db9b1567718d92cb33c58886afb9497", size = 360653, upload-time = "2026-08-28T12:17:10.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/88/7c548de1f6c2ade7c931a3282da73f9274fa6a1531091be682f89c85efb9/jupyter_client-8.10.0-py3-none-any.whl", hash = "sha256:5f73f24f22fa25192cfff6b23c051932a2473a797b05734aff495b392103e14e", size = 110184, upload-time = "2026-08-28T12:17:09.028Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "jupyter-events" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema", extra = ["format-nongpl"] }, + { name = "packaging" }, + { name = "python-json-logger" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/f8/475c4241b2b75af0deaae453ed003c6c851766dbc44d332d8baf245dc931/jupyter_events-0.12.1.tar.gz", hash = "sha256:faff25f77218335752f35f23c5fe6e4a392a7bd99a5939ccb9b8fbf594636cf3", size = 62854, upload-time = "2026-04-20T23:17:50.66Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl", hash = "sha256:c366585253f537a627da52fa7ca7410c5b5301fe893f511e7b077c2d93ec8bcf", size = 19512, upload-time = "2026-04-20T23:17:48.927Z" }, +] + +[[package]] +name = "jupyter-lsp" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/ff/1e4a61f5170a9a1d978f3ac3872449de6c01fc71eaf89657824c878b1549/jupyter_lsp-2.3.1.tar.gz", hash = "sha256:fdf8a4aa7d85813976d6e29e95e6a2c8f752701f926f2715305249a3829805a6", size = 55677, upload-time = "2026-04-02T08:10:06.749Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/e8/9d61dcbd1dce8ef418f06befd4ac084b4720429c26b0b1222bc218685eff/jupyter_lsp-2.3.1-py3-none-any.whl", hash = "sha256:71b954d834e85ff3096400554f2eefaf7fe37053036f9a782b0f7c5e42dadb81", size = 77513, upload-time = "2026-04-02T08:10:01.753Z" }, +] + +[[package]] +name = "jupyter-server" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "argon2-cffi" }, + { name = "jinja2" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "jupyter-events" }, + { name = "jupyter-server-terminals" }, + { name = "nbconvert" }, + { name = "nbformat" }, + { name = "overrides", marker = "python_full_version < '3.12'" }, + { name = "packaging" }, + { name = "prometheus-client" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "pyzmq" }, + { name = "send2trash" }, + { name = "terminado" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/e0/63b481d5b21f81fe23173dfc9eb25629fa1bc174fdc4a2c19d09c1ff8124/jupyter_server-2.21.0.tar.gz", hash = "sha256:70d9a1883f57d3576ea17f4ce061ec1a7aad7ef388d00428cfb7f5e4f0022271", size = 760357, upload-time = "2026-08-27T16:06:34.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/8a/cb97c2b353cb46cced413be8f742fa8fcd3cb1659524f27314494aa94968/jupyter_server-2.21.0-py3-none-any.whl", hash = "sha256:2ae2e5ce5e97268553e25aebe040197455673d925b5fc7995477153318160cf7", size = 393961, upload-time = "2026-08-27T16:06:31.814Z" }, +] + +[[package]] +name = "jupyter-server-terminals" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "terminado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/a7/bcd0a9b0cbba88986fe944aaaf91bfda603e5a50bda8ed15123f381a3b2f/jupyter_server_terminals-0.5.4.tar.gz", hash = "sha256:bbda128ed41d0be9020349f9f1f2a4ab9952a73ed5f5ac9f1419794761fb87f5", size = 31770, upload-time = "2026-01-14T16:53:20.213Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/2d/6674563f71c6320841fc300911a55143925112a72a883e2ca71fba4c618d/jupyter_server_terminals-0.5.4-py3-none-any.whl", hash = "sha256:55be353fc74a80bc7f3b20e6be50a55a61cd525626f578dcb66a5708e2007d14", size = 13704, upload-time = "2026-01-14T16:53:18.738Z" }, +] + +[[package]] +name = "jupyterlab" +version = "4.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-lru" }, + { name = "httpx" }, + { name = "ipykernel" }, + { name = "jinja2" }, + { name = "jupyter-builder" }, + { name = "jupyter-core" }, + { name = "jupyter-lsp" }, + { name = "jupyter-server" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/c0/45934229995d4c6e38192ca118d93185f60808f64157e3df3c5c28f8c5fd/jupyterlab-4.6.3.tar.gz", hash = "sha256:2e3db6e3a12495ebd188276e985bf5ac502fbde3d1e8628819920210008de498", size = 28319771, upload-time = "2026-08-10T18:50:57.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/47/242f46de028074651c9bd6d8000fc340ed0d3cdd1a0eae4387826123413a/jupyterlab-4.6.3-py3-none-any.whl", hash = "sha256:0a1ebc6567186f1eabd99536e94df7ed9e96d1e7c5ddf3e4406ae16e88abacb7", size = 17168312, upload-time = "2026-08-10T18:50:53.044Z" }, +] + +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, +] + +[[package]] +name = "jupyterlab-server" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "jinja2" }, + { name = "json5" }, + { name = "jsonschema" }, + { name = "jupyter-server" }, + { name = "packaging" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/2c/90153f189e421e93c4bb4f9e3f59802a1f01abd2ac5cf40b152d7f735232/jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c", size = 76996, upload-time = "2025-10-22T13:59:18.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830, upload-time = "2025-10-22T13:59:16.767Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + +[[package]] +name = "mistune" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/92/328a294a6de83bacb95bed01f04e0eaff4e3616ee359fc821a5dfc539b02/mistune-3.3.4.tar.gz", hash = "sha256:58b5c96d6fcb61190dfe5fae498d2b2065f99cf61e9649418fd54cf1ada86dfe", size = 121426, upload-time = "2026-07-22T05:22:30.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/e4/288365afae98953bc01de09f686f40d8ee84578135aa7767d5d4e60b5278/mistune-3.3.4-py3-none-any.whl", hash = "sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a", size = 66862, upload-time = "2026-07-22T05:22:29.419Z" }, +] + +[[package]] +name = "nbclient" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/a5/b3bae4b590c0cbcada2c63a34f7580024e834a8ba213e949a2f906705787/nbclient-0.11.0.tar.gz", hash = "sha256:04a134a5b087f2c5887f228aca155db50169b8cd9334dee6942c8e927e56081a", size = 62535, upload-time = "2026-06-05T07:52:41.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/c9/94d73e5a01c5b926c3fa2496e97d7a8dc28ed5a77c0b2ed712f1a62e6694/nbclient-0.11.0-py3-none-any.whl", hash = "sha256:ef7fa0d59d6e1d41103933d8a445a18d5de860ca6b613b87b8574accdb3c2895", size = 25288, upload-time = "2026-06-05T07:52:40.115Z" }, +] + +[[package]] +name = "nbconvert" +version = "7.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach", extra = ["css"] }, + { name = "defusedxml" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, +] + +[[package]] +name = "nbformat" +version = "5.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/72/b3446efab8756e7df4b8ec587f8e611cb5a7249e4323db480802f1d3be04/nbformat-5.11.1.tar.gz", hash = "sha256:32d4521c68c6e7d5b29c76defaeed9f42ea733142b9b19f88277ce10390b9c4d", size = 147775, upload-time = "2026-08-17T08:10:51.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/69/ee613f74085ca7103f79cd08d579c4f3177d1d26e5d3d9528d2d6536a707/nbformat-5.11.1-py3-none-any.whl", hash = "sha256:cc6698fa75f4fab8755ead786317815f13a6fee3b53311c0abb1a8b51d52f7ec", size = 79849, upload-time = "2026-08-17T08:10:50.18Z" }, +] + +[[package]] +name = "nest-asyncio2" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" }, +] + +[[package]] +name = "notebook-shim" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/d2/92fa3243712b9a3e8bafaf60aac366da1cada3639ca767ff4b5b3654ec28/notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb", size = 13167, upload-time = "2024-02-14T23:35:18.353Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307, upload-time = "2024-02-14T23:35:16.286Z" }, +] + +[[package]] +name = "overrides" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-json-logger" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/25/5473e46b179f8e8b4ad3aeeb36773d1701b7770eaf5e5bc2025c7303b598/python_json_logger-4.2.0.tar.gz", hash = "sha256:e371ebe22ec01e289850102091a2b1f6fc9e655c7f1f5f29073936756c290afa", size = 18211, upload-time = "2026-08-15T11:36:38.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/55/6467fde553886cb293e41538f3a8b4e4fd4688c6df242cf982162d8367fb/python_json_logger-4.2.0-py3-none-any.whl", hash = "sha256:158a52126fcd6869e09574d2b66272666f3dc8f468c62637ef9a1fa883719cb9", size = 14988, upload-time = "2026-08-15T11:36:36.821Z" }, +] + +[[package]] +name = "pywinpty" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/ef/2d27f30c59a67be7025b2d7858c8c2d282b74d66544b2384730b82de74fd/pywinpty-3.0.5.tar.gz", hash = "sha256:61db0db063de9865adbea66db294628f8577f608d9764a4c7d3384eeacc4e81b", size = 16223484, upload-time = "2026-06-11T00:11:58.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/ff/8bd91d4502b48bee0459bc912806fa512111905ab97d317e7cb1201b3542/pywinpty-3.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:b467dcad72365bc2205ed8b6e694e817d71de269d46e56cfe267dfa9d3d30e1b", size = 2092426, upload-time = "2026-06-10T23:40:23.292Z" }, + { url = "https://files.pythonhosted.org/packages/00/d2/dee13e67af2300a080dea30d4da8fdb05133a7eebb39171111f0530f7e88/pywinpty-3.0.5-cp310-cp310-win_arm64.whl", hash = "sha256:7dc4046ea8e4d7f0a16dae8dfcaeeda6df7ca3a9330444d2ba5bb96138fe0a91", size = 818083, upload-time = "2026-06-10T23:42:04.203Z" }, + { url = "https://files.pythonhosted.org/packages/b0/5c/31feb3dd82d1b33ae0bd09ca601edb993d9da1b7f0226b3336d4b4c39e1e/pywinpty-3.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:af7a8720c78776ddd6259b71dd567944f766a6cd67f8d2887fbc4973967bacda", size = 2092466, upload-time = "2026-06-10T23:44:24.453Z" }, + { url = "https://files.pythonhosted.org/packages/ee/fe/fe23e2229ffec0c10190cef5964f5c9b2dba179d23b69ae537b7ea90bcab/pywinpty-3.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:c2406f54f699eab75953fb75ce805f2ae55a33a957cd070890abd454fb4b7680", size = 818395, upload-time = "2026-06-10T23:41:56.93Z" }, + { url = "https://files.pythonhosted.org/packages/45/34/942cc95ca4e26489875aa8a95192766247a687379ec29543eebe73ec945f/pywinpty-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:d62946adf14b15b54c0b8d785f93fe18b04da23f4ad59e2e8c4612646e9abd23", size = 2090915, upload-time = "2026-06-10T23:43:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/5b9053004844139ea8bd86209c57ade12b134b2782f383a095784c8531ec/pywinpty-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:e9391c05fbfa7a992a97e831fc6849887b4014a614192e3d984a7ca59592b376", size = 815934, upload-time = "2026-06-10T23:41:42.384Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f4/2a464b9893cceb3b3f416356e94fdc3e1bca9476993927e4e6d99fe95382/pywinpty-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:48db1b0ad9d0a1b81dcaaa7163a99a7808deaceb0c1b2344716dc1fc090c3c4c", size = 2090471, upload-time = "2026-06-10T23:42:11.071Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2c/a138491a0afbdb50eb79395577bd326d4b0fbde7209417d1a8087ff2493a/pywinpty-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:2c6008fb2d3774b48693b2fcb7f2cc317ade9dc581289a964ffeeaf81307c9b5", size = 815518, upload-time = "2026-06-10T23:42:02.363Z" }, + { url = "https://files.pythonhosted.org/packages/6f/15/54400049a380582acd1282665c70fcf11e0bd3713679aca78e24c3aae738/pywinpty-3.0.5-cp313-cp313t-win_amd64.whl", hash = "sha256:22ce1b780d89821cc52daf6eac0708af22d93d000ce9c7c07e37489db8594598", size = 2089920, upload-time = "2026-06-10T23:44:13.395Z" }, + { url = "https://files.pythonhosted.org/packages/94/0c/6f24f3c0799f502259b24bdf841a99ad2b0d59df5c2525b4e2a286d14be2/pywinpty-3.0.5-cp313-cp313t-win_arm64.whl", hash = "sha256:9c2919a81bc5cfb09b86fc5a002112b2de95ca4304a07413cbeeb746a1307a5c", size = 814520, upload-time = "2026-06-10T23:43:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/f3cd1b1e5fc56517f54452c49f92049e7dd9ffc8a63de22a495581f50d04/pywinpty-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:03bb3c16d691d9242267201830bcd0e64a9b663170e9042bc84b210da9de15ac", size = 2090663, upload-time = "2026-06-10T23:43:59.845Z" }, + { url = "https://files.pythonhosted.org/packages/9d/dd/96d6cbfc6d9ddab5c1c2f92c26545ae8997446a2ba7ee2024cd43c81f49b/pywinpty-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:89c5c6ef08997a3b4b277b214a35fe15cab4dd6d119f0140aa71df5b1168fdbc", size = 815700, upload-time = "2026-06-10T23:40:50.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/36/d98087bce0acaa4cce7f196103cfa7be3f63ce65f52473bb3e38784ae5d9/pywinpty-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7b566165e0c5fdd6abe167a5ac8b954be6a843eb55a85946576d6bc1dea03d6d", size = 2090093, upload-time = "2026-06-10T23:40:58.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/fd/fe2b0db922ba052ce3976a08f3fc05d0c05047c8b4ebb6102e832b8ef563/pywinpty-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:24366280a8aa677323da87bec729cb3ea3b35367386cece0978bdc6e4695c690", size = 814517, upload-time = "2026-06-10T23:42:34.946Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/8d/5b3d5631c2f4b4b8862f64cd0c9eb777b5710eeb5125b4be8dd0a200a4c0/pyzmq-27.2.0.tar.gz", hash = "sha256:54d4259d1bfae24ecdb5ca79f7acc2eac6c286a02d6a0ae617797cb45f0726d3", size = 292316, upload-time = "2026-08-20T19:08:21.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/de/c9d653d686ec686bac6ae5f953c784ffc4d2e4a33f9c20d0326aff549ca8/pyzmq-27.2.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:480dba27b145373b5e103890f17969d891bc9e86746d6b8b29dd70b0d4addc62", size = 1458528, upload-time = "2026-08-20T19:06:11.455Z" }, + { url = "https://files.pythonhosted.org/packages/13/3a/e3ae8e56fdb87cefbef655dc1d808d5ac76eacf40af850c5d78fd81a7faf/pyzmq-27.2.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:722f0a6940be1a483c81029a271d950e04dc2ff113a42e21b3d2b7a0d8e59638", size = 985810, upload-time = "2026-08-20T19:06:13.409Z" }, + { url = "https://files.pythonhosted.org/packages/03/ee/0ace0abf6315f3f481388826f1e9218802d9b5bb4a01bae5ce42018c3b4e/pyzmq-27.2.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ee556ed1cf836f96de9d5e545563116426d4a94f21b8041fdc79408eff18ebb", size = 713840, upload-time = "2026-08-20T19:06:14.85Z" }, + { url = "https://files.pythonhosted.org/packages/3b/fd/aee8c87f4854a012e232a6557b1167b6cd207506f59f378954b2d8bb6ed1/pyzmq-27.2.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d64da42cae09e6b0c61368b4cc8ca80f23ce3af17584d08053f3dc957433d5ed", size = 888214, upload-time = "2026-08-20T19:06:16.295Z" }, + { url = "https://files.pythonhosted.org/packages/94/2f/9be34eab874a26aa91315be0b27f94d43d35dad544cbdf28bcd41691afb2/pyzmq-27.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:376981d106598beb70be384f44d8f589832fd0051d184d38d10043da3cc3b080", size = 1705252, upload-time = "2026-08-20T19:06:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/7f/26/362344e337d6b5905d65d0c205e91090b39b3b5a59ec9b84dbe88a5dd60c/pyzmq-27.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:40d96cb7a8f6a43aa9617c00215c2b73e1b5e4a1d6cbc9f5860ed7ac682599f0", size = 2075298, upload-time = "2026-08-20T19:06:19.27Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ee/3b76b91e2bb8c5c12f8ae9ee553eb6254ba0bb048c2f0dea7d945420a0d3/pyzmq-27.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4ebc7889b31bc11c72e9f17ba3ebb0a8b0911cce413f41b498e55383a94819a3", size = 1926812, upload-time = "2026-08-20T19:06:20.621Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ae/83d0740f125a6ef91d5ed29e560679e4c5beeaa3ab1fc87e5fe1128c296d/pyzmq-27.2.0-cp310-cp310-win32.whl", hash = "sha256:650c6cd7cb39a069e7048261efe66fce8bf2e0052c831a7a099b7a0f2ea860d7", size = 571057, upload-time = "2026-08-20T19:06:22.078Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b3/3e99a7af1c25e84f68c6c7b16c51b664dd51e12d0bc3d252a48d298e9a08/pyzmq-27.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:82a09aa67871d4f2fcafd47bf670fb93210b232a7c2d4b8a54676314edf04033", size = 643232, upload-time = "2026-08-20T19:06:23.431Z" }, + { url = "https://files.pythonhosted.org/packages/06/cb/785d002a08e630807141b102fd2740cc11623c366bb4606465450bd12e03/pyzmq-27.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:bad4813f270592cedf56977e31ac1fc374fb0f6f67ea5134a5e37c19cb429a8e", size = 567590, upload-time = "2026-08-20T19:06:24.664Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2e/8897afa4538707d86645f51cc50e66b2b84900edb1be9dc9af2c2fc04e5d/pyzmq-27.2.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:9216132843d139a123f243c07fe70f7487dce5041093dd77040f9adb5dc91872", size = 1457109, upload-time = "2026-08-20T19:06:26.022Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bc/dbce7bc1654fa25b1e68b9bad9e547906f581ce919c186a88ed951cb794c/pyzmq-27.2.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d41ebb260b69329b7d4a2936d44c872c86dd785355b51366c8b14e07ed7e9373", size = 985701, upload-time = "2026-08-20T19:06:27.481Z" }, + { url = "https://files.pythonhosted.org/packages/95/cf/6981738b57c83fef33f356141ad83bf51e92f2f70c9d5767affd1a699f07/pyzmq-27.2.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:468139ddb2e494d06e586bd3a6835077e8b3764560c8db552fe685c5867fc24e", size = 714285, upload-time = "2026-08-20T19:06:28.962Z" }, + { url = "https://files.pythonhosted.org/packages/50/b5/13657961a845e29c28a4e7ac4202999ec90b3bba1890a5469ce2ae90359d/pyzmq-27.2.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39755dc4a923021bd0677990ffdbc21cff0e1ee1cf07fe3817acea153ef4cb67", size = 888465, upload-time = "2026-08-20T19:06:30.4Z" }, + { url = "https://files.pythonhosted.org/packages/58/5a/ca7ee7a767413d4ba858e93748b95e30b35b8c139849fba94de4433ea2e5/pyzmq-27.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:714f8cbd66c7e405338d668f79d2fe83fe923defe348e843be998603cf92eeff", size = 1705731, upload-time = "2026-08-20T19:06:31.819Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8b/083f6184e4eba566c9a3cc9974b1b0fe327b7093788135ba8133edaa67a6/pyzmq-27.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1132805970045adb9f5f05dd57040978286a8e21a5475f2c2ddf1bc983b9a2c7", size = 2074243, upload-time = "2026-08-20T19:06:33.36Z" }, + { url = "https://files.pythonhosted.org/packages/57/f5/249362b664ae725d534c8843214fa9fd7fccd74532a19e24603954a88a7d/pyzmq-27.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b26f2d0493b79ce3c3112c8a12649418915582ba4707b8ed9f44febf2be71f42", size = 1926859, upload-time = "2026-08-20T19:06:34.796Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cc/23c613c15f06d879f13364d14c17e5e4e8304049411e96c1410e6e56c3ea/pyzmq-27.2.0-cp311-cp311-win32.whl", hash = "sha256:44f261eca7dfb9904ea2b56428f59ab693bbe2715c0413a701f17b067ebf877c", size = 570747, upload-time = "2026-08-20T19:06:36.337Z" }, + { url = "https://files.pythonhosted.org/packages/dc/bc/bbbcf89003c93f18e33665c26e3c48d75e3915c3dd22887f3a7aea2c5e26/pyzmq-27.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:8b86e04f55af0f4d8cd8ecf14c0b8b81ebc8fd66fa20126b753514628ecadc7e", size = 644845, upload-time = "2026-08-20T19:06:37.711Z" }, + { url = "https://files.pythonhosted.org/packages/14/c5/4635d0ba2b8493edf6d5541fff0b07fa1d986fdfc29c596a53a21e20f9af/pyzmq-27.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:917d601e9540098f580d2723d0ce6402cdb6f02bc8dc2de74e0dca6e13bffd1b", size = 567495, upload-time = "2026-08-20T19:06:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/153532fa53db30e116118164f3af269a1f3966b3e2ba32c89b12fe864bd8/pyzmq-27.2.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:591c8de5851c5ea372194469fe97587b97c3b641e9a70f31bb3474acbfde0241", size = 1431074, upload-time = "2026-08-20T19:06:40.601Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ef/c08b91248bb90a9efa81fa00ba81b69c157c74d0c5efbb2c319d91babb62/pyzmq-27.2.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:00e73942ef12cecbc7951c4a9104bb8ffaed742abb13af2da6833d90dd368cef", size = 973915, upload-time = "2026-08-20T19:06:42.037Z" }, + { url = "https://files.pythonhosted.org/packages/b4/78/a3a3a86c2b00fadb92ece1ca4f8f028d62b2ce9ac3526097239ab2d6fba9/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f8079d0521fe94bbb401fe9407578b28f3701627c8be2c9f7e0c5b77dcb0109", size = 697722, upload-time = "2026-08-20T19:06:43.325Z" }, + { url = "https://files.pythonhosted.org/packages/62/2c/d5828306f795e8d34676d266823b74e2101e0ad3760d12083de3e02abbb2/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dea74fd65f1fc5f7fe167916a473ebe6ed6174e5e5d9de11ea6583661be6cf43", size = 872258, upload-time = "2026-08-20T19:06:44.627Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/51253b78fd8739293e283407eeecb14215c02c71b6519af21f6eed8e69cd/pyzmq-27.2.0-cp312-abi3-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcc99ca132b667a4ed750afd42db4ea73288f18425a9b2e3c0af095665c491f5", size = 739591, upload-time = "2026-08-20T19:06:46.214Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3e/142c85b67a4c9678629b0cf6d5125b29663d75be69bfaa57a3cac344d780/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b8d5f66e4a8246cf77f7b8f7902af64f00553368fa0373c89d99b78f0ad79394", size = 1689031, upload-time = "2026-08-20T19:06:47.612Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ee/0776fb0f98ed1eb74d77240087fef0ab045b6ad15cb09555c6c5134c98ad/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:d1526b42a2e725b84ed226f37becedc250c6347594e5ed304e4e9aff68c9aec3", size = 2059547, upload-time = "2026-08-20T19:06:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/ec77f691a4aebe29ab6329f996fb0e0270c876a3016086e3ca6ef733bcae/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f707bcf2c1d007d14d70531d4dd7b41060881c73efa845580bf6faaf9ea24d42", size = 1910457, upload-time = "2026-08-20T19:06:50.783Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/1f5530ff4fc271b4597048371d5af972c2baab51be132ba15874e0327a6a/pyzmq-27.2.0-cp312-abi3-win32.whl", hash = "sha256:fdaaa4ea3242f6ad298eb5177eb042aea5c73c30e76d20caee7b15af20d24ec2", size = 563450, upload-time = "2026-08-20T19:06:52.307Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b83f7780dad22e0878e4c7bd9158ebd24ed12bc3d5e3a471cd0576f77ded/pyzmq-27.2.0-cp312-abi3-win_amd64.whl", hash = "sha256:2c218c6ab8bc447ba62054b581fd30209689d199c6ecb253f79615ca74a38e12", size = 628633, upload-time = "2026-08-20T19:06:53.809Z" }, + { url = "https://files.pythonhosted.org/packages/52/aa/3918b5ac7f9987bd9c421b065074fd7409ded88f856f2c704a24341877ec/pyzmq-27.2.0-cp312-abi3-win_arm64.whl", hash = "sha256:348d6fd3e4b81ae4580622ea8c2ea60224e84b2ac1b3be4482e6edc7de06e7a3", size = 556006, upload-time = "2026-08-20T19:06:55.242Z" }, + { url = "https://files.pythonhosted.org/packages/83/5e/d0541596b48c5a19f85dcbea83d6673d8e91681cdf853eb194c31fc9766e/pyzmq-27.2.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:c551b9e2f86dc625fcb1a032c0d68042678caf96a8dd7c28796766b673bd5b52", size = 1127193, upload-time = "2026-08-20T19:06:56.545Z" }, + { url = "https://files.pythonhosted.org/packages/50/9f/8c7411bb283982d46e6d56dca6a095678c87eb0398daead12776d9881ac2/pyzmq-27.2.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:288cc790da0e3064a14a38ddc56ba169dada8c8af4cb86518db2bcbd380eedbb", size = 1166833, upload-time = "2026-08-20T19:06:58.011Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/a849161ff88b2de9b991cc8ab332218824741122fdc4fdf222a5b822ac8c/pyzmq-27.2.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:3d45189c0c3c99f817b7fefff0d32eeef684cf33e1e3c0fc4281515357c54702", size = 1134452, upload-time = "2026-08-20T19:06:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/3c/34/ff4aaff0cfba2a4d7ad1a16ffedc52c6deb89fcf673d455085446b23f215/pyzmq-27.2.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d61910b52be5b2cd8b248dbcbe3a1b0275556a7d99fb613fc43323b546e273b8", size = 1167520, upload-time = "2026-08-20T19:07:01.283Z" }, + { url = "https://files.pythonhosted.org/packages/b6/07/42111e9dc1041d78b4443d6eb1b82b027f1a58178dc8a38385effbc72ad5/pyzmq-27.2.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3ab6eb88590e510ab16715c32dbba12000da9bee989fdadd9ee19a234c492eb7", size = 1466289, upload-time = "2026-08-20T19:07:02.738Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b4/def7a478458da78665840564161772e7e938600c32a89f28e8b221b54d2d/pyzmq-27.2.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecbdd131b9669f62d3a45afee5527c7ae9f141e4301267f21714c90bd21725f", size = 975868, upload-time = "2026-08-20T19:07:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/38/d5/e3e85f7fea37153097aaff49db9e33093909cc2a7b22c1ac4ebe546600fc/pyzmq-27.2.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3146385b94a760236c5eceff468a66a296a716ca98a2e0f9217b1518118466b1", size = 706054, upload-time = "2026-08-20T19:07:05.623Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ef/3b7d9449b223183222bf517245e1e53d5f1ab8c10be8b45f6a301b2f994a/pyzmq-27.2.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9846e881620dd62566ca76a53e384c3f37490faf4b9240aebc7498810dfca853", size = 878984, upload-time = "2026-08-20T19:07:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/be/a5/8b49dbd494f6dcfda69dc4cade322a4b02706ef4e3d30cc366d4e369899f/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d9527e3dbaef1edaeeb2446fa7379446814a43ade8adc7c4a5ebe69437815ddd", size = 1697489, upload-time = "2026-08-20T19:07:08.945Z" }, + { url = "https://files.pythonhosted.org/packages/da/5a/4bb8280901130c26ea25f0cbb4a6d39d94250860c6b3dbd912f1cf48fca7/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:56b48fa9d478a3af7254f397697a62f5ad3e1bb677e200b2701f0c290d97e5af", size = 2064236, upload-time = "2026-08-20T19:07:10.384Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/f433af66922554adb2b5f79e897018c8e19a90b9eaeb49c4814f8355ebe4/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bf0b6e4ce1bb089751c504c5493d6b0557eabd02dd21b76e9086cf964234b103", size = 1917424, upload-time = "2026-08-20T19:07:11.909Z" }, + { url = "https://files.pythonhosted.org/packages/36/81/ea1c1ae3f801d96ba2c269e056761ebcfe023476e651d3af2a7817962051/pyzmq-27.2.0-cp314-cp314t-win32.whl", hash = "sha256:fba8afcf265c6e9fbe1594cb045d4765c6c9a7d607653a8196067ef23566b843", size = 591103, upload-time = "2026-08-20T19:07:13.451Z" }, + { url = "https://files.pythonhosted.org/packages/8a/04/149a627707e780fa9f2c1ede3590c14fa6b18b5576d15744342622299a50/pyzmq-27.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d1bc1d380a91d954ed5fc9f12915dba014eed0978d2de05ee7ca688bdaac144a", size = 670215, upload-time = "2026-08-20T19:07:15.069Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/f9c3c1536c41ef3dbf765ea04218990e2056e558f98184ecd883767fc501/pyzmq-27.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c7cfb75caa83f5153c687e9d2107f64b5ef0ef0d6edd260d3ff920baaaa69101", size = 582252, upload-time = "2026-08-20T19:07:16.582Z" }, + { url = "https://files.pythonhosted.org/packages/fa/00/78fe097a304a408275747ce43f20428789130b059c5649956277c20f30cf/pyzmq-27.2.0-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:c5129a8fe43ecc49b99eb75616603d483a3c2fcaef504988fafe8ea392aea98b", size = 1134295, upload-time = "2026-08-20T19:07:17.94Z" }, + { url = "https://files.pythonhosted.org/packages/f2/83/1c36270658d2ee56e23a3f9ef5fbcb94cbd2f9fe966a6641f2f38e697162/pyzmq-27.2.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:baa2ce3485145653194d6c8c5beedd1e9f0bf46a0919c9fa2fe2204fc35b74d9", size = 1167492, upload-time = "2026-08-20T19:07:19.476Z" }, + { url = "https://files.pythonhosted.org/packages/58/b2/f0ae223438d7faa991f6feefdc823815f11cc604f898738376b59fd96515/pyzmq-27.2.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:e1ed46048d1920cabc96d952a0d5cfe4127ad8db572c335aae4e3c57b9278d7f", size = 1465992, upload-time = "2026-08-20T19:07:20.941Z" }, + { url = "https://files.pythonhosted.org/packages/59/46/fb56f3f37a6a0937b0e1d2885e808b5eedc171320bac85573cfae78fa9bc/pyzmq-27.2.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e0fa0bc6b1a184aee59b32efcd1b7f0e6d5b8f9387799e4c16a4cb66a86747d6", size = 976118, upload-time = "2026-08-20T19:07:22.577Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/a2c9bfd7c4d34eea1278493cd041bc000d41acb4463c89ceaad29dc813b6/pyzmq-27.2.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4bd6743e8bf854c3bfce892dd6578a514aabf128e37a4b2eafcf01856f7e44", size = 705968, upload-time = "2026-08-20T19:07:24.019Z" }, + { url = "https://files.pythonhosted.org/packages/d6/12/b906b269116b6591dc15c0acc5d04c043957c8a531d336999731f4b1d899/pyzmq-27.2.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95369ed6626afcfe2ac89832fb1b917c077fbeb905fbbe5d918349ce0222b89b", size = 879011, upload-time = "2026-08-20T19:07:25.428Z" }, + { url = "https://files.pythonhosted.org/packages/12/13/f96359534bfb77651c15f1fbfc4bfdd7ec3489d23f434706d39598dd0dcd/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40124779c3a56ad5d91902df1ff89159cb414b6c1a0ee697abcc66cf5e6db62d", size = 1697496, upload-time = "2026-08-20T19:07:26.821Z" }, + { url = "https://files.pythonhosted.org/packages/21/b4/2c007ae5f2fe5eca86cbfbc874ed86b5135f2f7812615dfd78606d3c93f6/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:ec8a318dfc27c7d946651b3d9e8025d5734f30c168a822195601827207bac09b", size = 2064347, upload-time = "2026-08-20T19:07:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/9b/88/767af3a6630c15215f3a66700ec79598a375edd1fdc9d75a3ad522178c01/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:88c0fac061bac269076edeb3a209acefc96cd6167c239daf1c2b404ac48d7012", size = 1917360, upload-time = "2026-08-20T19:07:29.693Z" }, + { url = "https://files.pythonhosted.org/packages/35/c1/80dd2d20d6e57bc68e1dce1e84bf3e76c9577c1bf728199985c8b4ea0fd1/pyzmq-27.2.0-cp315-cp315t-win32.whl", hash = "sha256:ac126d48cf18aa955daabef43bf0009ff76ad4deee437d09ecf15388214b5beb", size = 591073, upload-time = "2026-08-20T19:07:31.341Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b5/33b781666f3f52ae834bc9c8e38f4f0483a826c5a91cccc993292007bf10/pyzmq-27.2.0-cp315-cp315t-win_amd64.whl", hash = "sha256:edce90a1e588ec63adbf612cc0ad582de4169cd216c7ae53c15f42a2ee902f35", size = 670701, upload-time = "2026-08-20T19:07:32.895Z" }, + { url = "https://files.pythonhosted.org/packages/6e/97/bc4f0edefb992df4fdebcf9f0cc40f631cd4ed277e1ed59ef2cd99a5c8c5/pyzmq-27.2.0-cp315-cp315t-win_arm64.whl", hash = "sha256:a843094b4d3d633bc3623e47a2ff50742d6af02bc1f7606aa2e67e971e21878d", size = 581985, upload-time = "2026-08-20T19:07:34.19Z" }, + { url = "https://files.pythonhosted.org/packages/8c/26/1a7cd2d8e4e3c27d83a46960e22101b69f527843be140cb3375267aa8ca6/pyzmq-27.2.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:770a37f28ddfbe1d2c40a2e3ce37e5fd10831daa6ae9634105aa8a5d23507b00", size = 875015, upload-time = "2026-08-20T19:07:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ed/c8daf770ca31eb293bef40f801f05148e0a96ad538d55ec3bceb226c91ec/pyzmq-27.2.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b398c5fe102b41e1559f7ffdae760aabd5f432d73b047b4ae0eac4e01cb594d2", size = 774400, upload-time = "2026-08-20T19:07:53.596Z" }, + { url = "https://files.pythonhosted.org/packages/64/6f/b958b0785eb15ab7a78913e3df8de705dbf226ce1d0004998fa4872bdd59/pyzmq-27.2.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0e1af01858d6dc0c09cea57f9cb1ddf4601f04897b6bb1efc3a2038123c87d79", size = 877993, upload-time = "2026-08-20T19:07:55.192Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/7a070d0e9911441061402013f0c64e5772ee781704c5dae093e6b051c6f6/pyzmq-27.2.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:211350c3ccd4746bc5a85e8fe961bad1f7f2f274f67cf1f785fad7f96f562eea", size = 614493, upload-time = "2026-08-20T19:07:56.77Z" }, + { url = "https://files.pythonhosted.org/packages/b3/28/833485224e1bd8960cbf3539db465ab2bf23b50de885dc4225a637480539/pyzmq-27.2.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dde5e291548ca0f397623b5e523db5c90172b32aa4fd3ba464a79ea31a580b43", size = 780573, upload-time = "2026-08-20T19:07:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/90/8e/52239b9b5fe4f9cf77e272e6a8c548c01ba407d08d3aeeb3220816669ef1/pyzmq-27.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:94242bd4de6af7e74665e14a88630bccd615057f6acfaf08a3a432551d604645", size = 553963, upload-time = "2026-08-20T19:08:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/93/22/7187a1f0bf2b8bf8dc6b91762438fb9b472f684f2dc4cb74a24bf8957943/pyzmq-27.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a7c1144dc61777938e932a2c9011b980b89fd8ff3733033b34c44c299187a6e1", size = 875015, upload-time = "2026-08-20T19:08:01.692Z" }, + { url = "https://files.pythonhosted.org/packages/92/71/09b71620ad52bad4eb68b1516978ecaf52ef623c3fa16e0732a03cf3274c/pyzmq-27.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:c218b816220d05acf6ab1bafca58926d95cbcc5fec5024724666030466308f0c", size = 774395, upload-time = "2026-08-20T19:08:03.108Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/5c8eb9994a14ff5ee5b0cada339421748746c95aee0280c8b656741e8749/pyzmq-27.2.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ae6ebbc0bfe5a21ce21e32ba567bf73df2d93888109c65acbd42506cf9395759", size = 877994, upload-time = "2026-08-20T19:08:04.724Z" }, + { url = "https://files.pythonhosted.org/packages/97/64/e22094c5555e550b6450ecfcceb6a1205d893d9a18ae27764c8c45acfb16/pyzmq-27.2.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:679b5b1dde326a921ea2c9ec1f9ea3115bfe1b4735779bbc6eb0473a0ed93f71", size = 614492, upload-time = "2026-08-20T19:08:06.487Z" }, + { url = "https://files.pythonhosted.org/packages/d2/28/5b1042899caed18278c56d54a502f5254d463afe8aea1acbecc98e053391/pyzmq-27.2.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5c6d8744d10b5e1eadd90a7c58f8546acf6bf680ee463f7e6ada09ad6c9f802", size = 780574, upload-time = "2026-08-20T19:08:07.987Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/f134603a671c114c6b56eb912bba890f09e2d43b8a28d243be5a5507cf2f/pyzmq-27.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3ee8dd7031d5e23f632e0e7eee67183ca7d2536e0de35dc1e5d69f3471a791e8", size = 553961, upload-time = "2026-08-20T19:08:09.651Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, +] + +[[package]] +name = "rfc3987-syntax" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lark" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.11' and python_full_version < '3.14'", +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "send2trash" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/f0/184b4b5f8d00f2a92cf96eec8967a3d550b52cf94362dad1100df9e48d57/send2trash-2.1.0.tar.gz", hash = "sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459", size = 17255, upload-time = "2026-01-14T06:27:36.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/78/504fdd027da3b84ff1aecd9f6957e65f35134534ccc6da8628eb71e76d3f/send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c", size = 17610, upload-time = "2026-01-14T06:27:35.218Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "terminado" +version = "0.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "os_name != 'nt'" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701, upload-time = "2024-03-12T14:34:39.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, +] + +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/d3/343e5bb989d6515b1646cf3d40135d73f3d5e45339bded401b56cdac24dd/tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f", size = 520493, upload-time = "2026-08-07T02:12:42.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/d5/007086fd8df5489338e204f65adce33fd4f21a4999dbb2b9cff2f897b5f4/tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403", size = 449487, upload-time = "2026-08-07T02:12:28.682Z" }, + { url = "https://files.pythonhosted.org/packages/70/c8/5a24a99495903f594f6a199dd7beead1cbc0a13e2cb9102727bcaaf2a997/tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb", size = 447649, upload-time = "2026-08-07T02:12:30.306Z" }, + { url = "https://files.pythonhosted.org/packages/6e/de/f2e733f386b85962d1b1dc82cd63d169b5b4580062b35397eac9244a41fe/tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58", size = 450707, upload-time = "2026-08-07T02:12:31.95Z" }, + { url = "https://files.pythonhosted.org/packages/0b/94/20efeee9a01c141e9ac47c397f81679dfda24b32768fc4fff24e76d36c2c/tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808", size = 451677, upload-time = "2026-08-07T02:12:33.512Z" }, + { url = "https://files.pythonhosted.org/packages/42/ec/a96ccb8ccf0de2b7bc2c5fa1608a4803735018242e90c4882365a9fd418f/tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22", size = 451510, upload-time = "2026-08-07T02:12:35.346Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/93185859245ad3f00e62175f29607346788b696369347f0146e0421286bb/tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195", size = 450917, upload-time = "2026-08-07T02:12:36.963Z" }, + { url = "https://files.pythonhosted.org/packages/97/cf/fe33cf062834487d34d1559746a4a12521033c22645b6d74d4bca702e018/tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836", size = 451952, upload-time = "2026-08-07T02:12:38.512Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e1/468ad54333e92ccb62627e62cb88e5fc14a2171daa67ed47b1b8542d5b86/tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee", size = 452391, upload-time = "2026-08-07T02:12:39.971Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3e/cd5e4f06e34cde33b8ef66cf36aa2b5ad46354cc1af7d2136bbe365fee1d/tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26", size = 451411, upload-time = "2026-08-07T02:12:41.469Z" }, +] + +[[package]] +name = "traitlets" +version = "5.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/2e/a7fbfe268c8a3b32546930c0297c101d65a4a14c304ad5790a9f478f0e4e/traitlets-5.16.1.tar.gz", hash = "sha256:ed900c2b631aa3a112811139fa97b8d2c3bad5e989656bba4b7e52c7852c18c1", size = 166137, upload-time = "2026-08-03T08:32:36.848Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/66/0d785f0bc5e4315a96c989bb476d0fc07ea4f85132550c7b156ca2035d52/traitlets-5.16.1-py3-none-any.whl", hash = "sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b", size = 86211, upload-time = "2026-08-03T08:32:34.48Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/57/ed58088fafdf4c55a0ad6bde846502567645424d7ebf325230b9237f4085/wcwidth-0.8.3.tar.gz", hash = "sha256:d128512515fbf4612e0ff21fd6380399210318b7b54a9af59dff8454cf9730eb", size = 1458450, upload-time = "2026-08-28T18:10:06.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/0e/57f6bb3024a597b2e8ec4aee710ffe62ddc95af2e2bb1ee7a7abdc22c68c/wcwidth-0.8.3-py3-none-any.whl", hash = "sha256:d5b73dba6158a595ec9370350e7f2637bcac8d6c5e4fde34f30fcffb6103a5e4", size = 331669, upload-time = "2026-08-28T18:10:04.909Z" }, +] + +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, +] + +[[package]] +name = "webencodings" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/a0/8fd707bcb776a7be556bad06a2ea5fb9bd519df78ef8e26f70ccf0f38bff/webencodings-0.6.1.tar.gz", hash = "sha256:565f9ad031c702dae404e27a099e3e09186a3ab1b9520f06d215502b651fd910", size = 15001, upload-time = "2026-08-15T14:22:57.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl", hash = "sha256:7fab6269c8bf237c657876b52058ccb182e861518d1c695c1a9aaa8c1c105d5b", size = 8745, upload-time = "2026-08-15T14:22:56.31Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393827c650c6296660ce40374ac22d99ab9aea41f9d2a2/websocket_client-1.9.2.tar.gz", hash = "sha256:0fcb57545848be86992e128218fd96dd87a6769ffdb1a968dff79632b85604d0", size = 84110, upload-time = "2026-08-31T14:08:40.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" }, +]