From 803422b0ca8043e6ad38fa3896ebbb51748c58e0 Mon Sep 17 00:00:00 2001
From: Pascal
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" },
+]