diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index a2e64179..2b39b197 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -11,3 +11,13 @@ updates:
directory: /
schedule:
interval: daily
+ - package-ecosystem: uv
+ directory: /
+ schedule:
+ interval: daily
+ open-pull-requests-limit: 10
+
+ - package-ecosystem: pre-commit
+ directory: /
+ schedule:
+ interval: daily
diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml
new file mode 100644
index 00000000..3e5ceda6
--- /dev/null
+++ b/.github/workflows/autofix.yml
@@ -0,0 +1,37 @@
+---
+name: autofix.ci
+
+on:
+ pull_request:
+ branches: [main]
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ autofix:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+ fetch-tags: true
+ persist-credentials: false
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v9.0.0
+
+ - name: Run fixers
+ uses: j178/prek-action@v3.0.0
+ with:
+ prek-version: 0.4.11
+ extra-args: >-
+ --all-files --hook-stage pre-commit --no-fail-fast --verbose
+ env:
+ UV_NO_CACHE: '1'
+ UV_PYTHON: 3.14
+
+ - uses: autofix-ci/action@v1.3.4
+ if: always()
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b989bfe0..663a6dc6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,5 +1,4 @@
---
-
name: Test
on:
@@ -12,45 +11,43 @@ on:
# Run at 1:00 every day
- cron: 0 1 * * *
+permissions: {}
+
jobs:
build:
-
strategy:
matrix:
- python-version: ['3.13']
+ python-version: ['3.14']
platform: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
- name: Install uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@v9.0.0
with:
enable-cache: true
cache-dependency-glob: '**/pyproject.toml'
- # We require a lower coverage for Windows as we do not
- # create binaries on Windows.
- - id: set-min-coverage
- run: |
- if [ ${{ runner.os }} == 'Windows' ]; then
- echo "min_coverage=60" >> "$GITHUB_OUTPUT"
- else
- echo "min_coverage=100" >> "$GITHUB_OUTPUT"
- fi
- shell: bash
-
- name: Run tests
run: |
# Using -s is useful but also it is required to avoid
# https://github.com/pallets/click/issues/824.
- uv --verbose run --extra=dev pytest -s -vvv --cov-fail-under ${{ steps.set-min-coverage.outputs.min_coverage}} --cov=src/ --cov=tests . --cov-report=xml
+ uv --verbose run --extra=dev pytest -s -vvv --cov-fail-under 100 --cov=src/ --cov=tests .
env:
UV_PYTHON: ${{ matrix.python-version }}
- - name: Upload coverage to Codecov
- uses: codecov/codecov-action@v5
- with:
- fail_ci_if_error: true
- token: ${{ secrets.CODECOV_TOKEN }}
+ completion-ci:
+ needs: build
+ runs-on: ubuntu-latest
+ if: always() # Run even if one matrix job fails
+ steps:
+ - name: Check matrix job status
+ run: |-
+ if ! ${{ needs.build.result == 'success' }}; then
+ echo "One or more matrix jobs failed"
+ exit 1
+ fi
diff --git a/.github/workflows/dependabot-merge.yml b/.github/workflows/dependabot-merge.yml
index 5238c9f6..69fa5303 100644
--- a/.github/workflows/dependabot-merge.yml
+++ b/.github/workflows/dependabot-merge.yml
@@ -10,13 +10,8 @@ permissions:
jobs:
dependabot:
runs-on: ubuntu-latest
- if: github.actor == 'dependabot[bot]'
+ if: github.event.pull_request.user.login == 'dependabot[bot]'
steps:
- - name: Dependabot metadata
- id: metadata
- uses: dependabot/fetch-metadata@v2
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable auto-merge for Dependabot PRs
run: gh pr merge --auto --merge "$PR_URL"
env:
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 91b21ce9..c825288b 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -1,5 +1,4 @@
---
-
name: Lint
on:
@@ -12,37 +11,55 @@ on:
# Run at 1:00 every day
- cron: 0 1 * * *
+permissions: {}
+
jobs:
build:
-
strategy:
matrix:
- python-version: ['3.13']
+ python-version: ['3.14']
platform: [ubuntu-latest, windows-latest]
+ hook-stage: [pre-commit, pre-push, manual]
runs-on: ${{ matrix.platform }}
steps:
- - uses: actions/checkout@v4
- # We need our tags in order to calculate the version
+ - uses: actions/checkout@v7 # We need our tags in order to calculate the version
# in the Sphinx setup.
+
with:
+ persist-credentials: false
fetch-depth: 0
fetch-tags: true
- name: Install uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@v9.0.0
with:
enable-cache: true
cache-dependency-glob: '**/pyproject.toml'
- name: Lint
- run: |
- uv run --extra=dev pre-commit run --all-files --hook-stage pre-commit --verbose
- uv run --extra=dev pre-commit run --all-files --hook-stage pre-push --verbose
- uv run --extra=dev pre-commit run --all-files --hook-stage manual --verbose
+ uses: j178/prek-action@v3.0.0
+ with:
+ prek-version: 0.4.11
+ extra-args: >-
+ --all-files --hook-stage ${{ matrix.hook-stage }} --verbose
env:
+ UV_NO_CACHE: '1'
UV_PYTHON: ${{ matrix.python-version }}
+ # hadolint is not available on Windows.
+ # nixfmt depends on the Haskell `unix` package, which is
+ # not buildable on Windows.
+ SKIP: ${{ runner.os == 'Windows' && 'hadolint,nixfmt' || '' }}
- - uses: pre-commit-ci/lite-action@v1.1.0
- if: always()
+ completion-lint:
+ needs: build
+ runs-on: ubuntu-latest
+ if: always() # Run even if one matrix job fails
+ steps:
+ - name: Check matrix job status
+ run: |-
+ if ! ${{ needs.build.result == 'success' }}; then
+ echo "One or more matrix jobs failed"
+ exit 1
+ fi
diff --git a/.github/workflows/publish-site.yml b/.github/workflows/publish-site.yml
index 1b4e5e4f..3541c9d3 100644
--- a/.github/workflows/publish-site.yml
+++ b/.github/workflows/publish-site.yml
@@ -17,10 +17,11 @@ jobs:
pages: write
id-token: write
steps:
- - uses: actions/checkout@v4
- # We need our tags in order to calculate the version
+ - uses: actions/checkout@v7 # We need our tags in order to calculate the version
# in the Sphinx setup.
+
with:
+ persist-credentials: false
fetch-depth: 0
fetch-tags: true
@@ -29,7 +30,7 @@ jobs:
with:
documentation_path: docs/source
pyproject_extras: dev
- python_version: '3.13'
+ python_version: '3.14'
sphinx_build_options: -W
publish: ${{ github.ref_name == 'main' }}
checkout: false
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a5a4d9c4..02d0e005 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,13 +1,17 @@
---
-
name: Release
on: workflow_dispatch
+permissions:
+ contents: read
+
jobs:
build:
name: Publish a release
runs-on: ubuntu-latest
+ outputs:
+ new_tag: ${{ steps.tag_version.outputs.new_tag }}
# Specifying an environment is strongly recommended by PyPI.
# See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing.
@@ -21,7 +25,7 @@ jobs:
contents: write
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
with:
# See
# https://github.com/stefanzweifel/git-auto-commit-action?tab=readme-ov-file#push-to-protected-branches
@@ -34,7 +38,7 @@ jobs:
fetch-depth: 0
- name: Install uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@v9.0.0
with:
enable-cache: true
cache-dependency-glob: '**/pyproject.toml'
@@ -48,26 +52,33 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- - name: Get the changelog underline
- id: changelog_underline
- run: |
- underline="$(echo "${{ steps.calver.outputs.release }}" | tr -c '\n' '-')"
- echo "underline=${underline}" >> "$GITHUB_OUTPUT"
+ # towncrier writes the rendered notes to stdout (informational
+ # chatter goes to stderr), so this is the curated release body for
+ # this version, not github-tag-action's commit-derived changelog.
+ - name: Generate the GitHub release notes
+ env:
+ RELEASE: ${{ steps.calver.outputs.release }}
+ run: uv run --extra=release towncrier build --draft --version "$RELEASE" >
+ release-notes.md
- - name: Update changelog
- uses: jacobtomlinson/gha-find-replace@v3
- with:
- find: "Next\n----"
- replace: "Next\n----\n\n${{ steps.calver.outputs.release }}\n${{ steps.changelog_underline.outputs.underline\
- \ }}"
- include: CHANGELOG.rst
- regex: false
+ # Assemble the same fragments into CHANGELOG.rst under a new
+ # ``$RELEASE`` section and delete the consumed fragment files.
+ - name: Update the changelog
+ env:
+ RELEASE: ${{ steps.calver.outputs.release }}
+ run: uv run --extra=release towncrier build --yes --version "$RELEASE"
- - uses: stefanzweifel/git-auto-commit-action@v5
+ - name: Update VERSION file for Nix flake
+ env:
+ RELEASE: ${{ steps.calver.outputs.release }}
+ run: |
+ echo "$RELEASE" > VERSION
+
+ - uses: stefanzweifel/git-auto-commit-action@v7
id: commit
with:
- commit_message: Bump CHANGELOG
- file_pattern: CHANGELOG.rst
+ commit_message: Bump CHANGELOG and VERSION
+ file_pattern: CHANGELOG.rst newsfragments VERSION
# Error if there are no changes.
skip_dirty_check: true
@@ -81,16 +92,20 @@ jobs:
commit_sha: ${{ steps.commit.outputs.commit_hash }}
- name: Checkout the latest tag - the one we just created
+ env:
+ NEW_TAG: ${{ steps.tag_version.outputs.new_tag }}
run: |
git fetch --tags
- git checkout ${{ steps.tag_version.outputs.new_tag }}
+ git checkout "$NEW_TAG"
- name: Build a binary wheel and a source tarball
id: build-wheel
+ env:
+ NEW_TAG: ${{ steps.tag_version.outputs.new_tag }}
run: |
sudo rm -rf dist/ build/
git fetch --tags
- git checkout ${{ steps.tag_version.outputs.new_tag }}
+ git checkout "$NEW_TAG"
uv build --sdist --wheel --out-dir dist/
WHEEL="$(ls dist/*.whl)"
uv run --extra=release check-wheel-contents "${WHEEL}"
@@ -108,11 +123,13 @@ jobs:
# the pushed version.
# Here, we give PyPI time to propagate the package.
- name: Install package from PyPI
- uses: nick-fields/retry@v3
+ uses: nick-fields/retry@v4
+ env:
+ RELEASE: ${{ steps.calver.outputs.release }}
with:
timeout_seconds: 5
max_attempts: 50
- command: uv pip install --refresh vws-cli==${{ steps.calver.outputs.release }}
+ command: uv pip install --refresh vws-cli=="$RELEASE"
- name: Set up Homebrew filename
id: set-homebrew-filename
@@ -122,14 +139,18 @@ jobs:
# We still hit the race condition, so we have a retry here too.
- name: Create a Homebrew recipe
id: homebrew-create
- uses: nick-fields/retry@v3
+ uses: nick-fields/retry@v4
+ env:
+ RELEASE: ${{ steps.calver.outputs.release }}
+ HOMEBREW_FILENAME: ${{ steps.set-homebrew-filename.outputs.filename }}
with:
timeout_seconds: 5
max_attempts: 50
command: |
- uv run --no-cache --with="vws-cli==${{ steps.calver.outputs.release }}" --extra=release poet --formula vws-cli > ${{ steps.set-homebrew-filename.outputs.filename }}
+ uv run --no-cache --with="vws-cli==$RELEASE" --extra=release poet --formula vws-cli > "$HOMEBREW_FILENAME"
- name: Update Homebrew description
+ id: update_homebrew_description
uses: jacobtomlinson/gha-find-replace@v3
with:
find: desc "Shiny new formula"
@@ -137,14 +158,14 @@ jobs:
include: ${{ steps.set-homebrew-filename.outputs.filename }}
regex: false
- - name: Fix Homebrew class name
- uses: jacobtomlinson/gha-find-replace@v3
- with:
- find: class VWSCLI
- replace: class VwsCli
- include: ${{ steps.set-homebrew-filename.outputs.filename }}
- regex: false
-
+ - name: Check Update Homebrew description was modified
+ env:
+ MODIFIED_FILES: ${{ steps.update_homebrew_description.outputs.modifiedFiles }}
+ run: |
+ if [ "$MODIFIED_FILES" = "0" ]; then
+ echo "Error: No files were modified when updating Homebrew description"
+ exit 1
+ fi
- name: Push Homebrew Recipe
uses: dmnemec/copy_file_to_another_repo_action@main
env:
@@ -161,38 +182,313 @@ jobs:
user_name: adamtheturtle
commit_message: Bump VWS CLI Homebrew recipe
- - name: Create Linux binary for Vuforia Cloud Reco
+ - name: Create a GitHub release
+ uses: ncipollo/release-action@v1
+ with:
+ tag: ${{ steps.tag_version.outputs.new_tag }}
+ makeLatest: true
+ name: Release ${{ steps.tag_version.outputs.new_tag }}
+ bodyFile: release-notes.md
+
+ build-linux:
+ name: Build Linux binary (${{ matrix.binary.name }})
+ needs: build
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ binary:
+ - name: vuforia-cloud-reco-linux
+ spec: bin/vuforia-cloud-reco.py
+ - name: vws-linux
+ spec: bin/vuforia-web-services.py
+ - name: vumark-linux
+ spec: bin/vumark.py
+
+ permissions:
+ contents: write
+
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+ ref: ${{ needs.build.outputs.new_tag }}
+
+ # We have a race condition.
+ # In particular, we push to PyPI and then immediately try to install
+ # the pushed version.
+ # Here, we give PyPI time to propagate the package.
+ # We normalize the version (e.g., 2026.01.22 -> 2026.1.22) for PyPI lookup.
+ - name: Wait for PyPI propagation
+ uses: nick-fields/retry@v4
+ env:
+ NEW_TAG: ${{ needs.build.outputs.new_tag }}
+ with:
+ timeout_seconds: 10
+ max_attempts: 50
+ command: |
+ normalized_version=$(echo "$NEW_TAG" | sed -E 's/\.0+([0-9])/.\1/g')
+ curl -sf "https://pypi.org/pypi/vws-cli/${normalized_version}/json" > /dev/null
+
+ - name: Create requirements file
+ env:
+ NEW_TAG: ${{ needs.build.outputs.new_tag }}
+ run: echo "vws-cli==$NEW_TAG" > requirements.txt
+
+ - name: Create Linux binary
uses: sayyid5416/pyinstaller@v1
with:
- python_ver: '3.13'
+ python_ver: '3.14'
+ pyinstaller_ver: ==6.12.0
+ spec: ${{ matrix.binary.spec }}
+ requirements: requirements.txt
+ options: --onefile, --name "${{ matrix.binary.name }}"
+ upload_exe_with_name: ${{ matrix.binary.name }}
+ clean_checkout: false
+
+ - name: Upload Linux binary to release
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NEW_TAG: ${{ needs.build.outputs.new_tag }}
+ run: |
+ gh release upload "$NEW_TAG" dist/${{ matrix.binary.name }} --clobber
+
+ publish-docker:
+ name: Publish Docker image
+ needs: build
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: read
+ # This is needed for pushing Docker images to ghcr.io.
+ packages: write
+
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+ ref: ${{ needs.build.outputs.new_tag }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v4
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v4.6.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # We have a race condition.
+ # In particular, we push to PyPI and then the Docker build
+ # fetches packages from PyPI inside the container.
+ # The Docker build may hit a different CDN node than the previous
+ # check, so we retry.
+ - name: Build and push Docker image
+ uses: nick-fields/retry@v4
+ env:
+ NEW_TAG: ${{ needs.build.outputs.new_tag }}
+ with:
+ timeout_minutes: 10
+ max_attempts: 5
+ command: |-
+ docker buildx build \
+ --push \
+ --platform linux/amd64,linux/arm64 \
+ --build-arg VWS_CLI_VERSION="$NEW_TAG" \
+ --tag ghcr.io/vws-python/vws-cli:"$NEW_TAG" \
+ --tag ghcr.io/vws-python/vws-cli:latest \
+ .
+
+ build-windows:
+ name: Build Windows binaries
+ needs: build
+ runs-on: windows-latest
+
+ permissions:
+ contents: write
+
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+ ref: ${{ needs.build.outputs.new_tag }}
+
+ - name: Set up Python
+ uses: actions/setup-python@v7
+ with:
+ python-version: '3.14'
+
+ # We have a race condition.
+ # In particular, we push to PyPI and then immediately try to install
+ # the pushed version.
+ # Here, we give PyPI time to propagate the package.
+ # We normalize the version (e.g., 2026.01.22 -> 2026.1.22) for PyPI lookup.
+ - name: Wait for PyPI propagation
+ uses: nick-fields/retry@v4
+ env:
+ NEW_TAG: ${{ needs.build.outputs.new_tag }}
+ with:
+ timeout_seconds: 10
+ max_attempts: 50
+ shell: bash
+ command: |
+ normalized_version=$(echo "$NEW_TAG" | sed -E 's/\.0+([0-9])/.\1/g')
+ curl -sf "https://pypi.org/pypi/vws-cli/${normalized_version}/json" > /dev/null
+
+ - name: Create requirements file
+ env:
+ NEW_TAG: ${{ needs.build.outputs.new_tag }}
+ run: echo "vws-cli==$NEW_TAG" > requirements.txt
+
+ - name: Create Windows binary for Vuforia Cloud Reco
+ uses: sayyid5416/pyinstaller@v1
+ with:
+ python_ver: '3.14'
pyinstaller_ver: ==6.12.0
spec: bin/vuforia-cloud-reco.py
- requirements: '`echo ${{ steps.build-wheel.outputs.wheel_filename }} > requirements.txt
- && echo requirements.txt`'
- options: --onefile, --name "vuforia-cloud-reco-linux"
- upload_exe_with_name: vuforia-cloud-reco-linux
+ requirements: requirements.txt
+ options: --onefile, --name "vuforia-cloud-reco-windows"
+ upload_exe_with_name: vuforia-cloud-reco-windows
clean_checkout: false
- - name: Create Linux binary for Vuforia Web Services
+ - name: Create Windows binary for Vuforia Web Services
uses: sayyid5416/pyinstaller@v1
with:
- python_ver: '3.13'
+ python_ver: '3.14'
pyinstaller_ver: ==6.12.0
spec: bin/vuforia-web-services.py
- requirements: '`echo ${{ steps.build-wheel.outputs.wheel_filename }} > requirements.txt
- && echo requirements.txt`'
- options: --onefile, --name "vws-linux"
- upload_exe_with_name: vws-linux
+ requirements: requirements.txt
+ options: --onefile, --name "vws-windows"
+ upload_exe_with_name: vws-windows
clean_checkout: false
- - name: Create a GitHub release
- uses: ncipollo/release-action@v1
+ - name: Create Windows binary for VuMark generation
+ uses: sayyid5416/pyinstaller@v1
with:
- # Use specific artifact names (not a glob) so that we get a clear
- # error if the artifact does not exist for some reason.
- artifacts: dist/vws-linux,dist/vuforia-cloud-reco-linux
- artifactErrorsFailBuild: true
- tag: ${{ steps.tag_version.outputs.new_tag }}
- makeLatest: true
- name: Release ${{ steps.tag_version.outputs.new_tag }}
- body: ${{ steps.tag_version.outputs.changelog }}
+ python_ver: '3.14'
+ pyinstaller_ver: ==6.12.0
+ spec: bin/vumark.py
+ requirements: requirements.txt
+ options: --onefile, --name "vumark-windows"
+ upload_exe_with_name: vumark-windows
+ clean_checkout: false
+
+ - name: Upload Windows binaries to release
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NEW_TAG: ${{ needs.build.outputs.new_tag }}
+ run: |-
+ gh release upload "$NEW_TAG" dist/vws-windows.exe --clobber
+ gh release upload "$NEW_TAG" dist/vuforia-cloud-reco-windows.exe --clobber
+ gh release upload "$NEW_TAG" dist/vumark-windows.exe --clobber
+
+ build-macos:
+ name: Build macOS binaries
+ needs: build
+ runs-on: macos-latest
+
+ permissions:
+ contents: write
+
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+ ref: ${{ needs.build.outputs.new_tag }}
+
+ # We have a race condition.
+ # In particular, we push to PyPI and then immediately try to install
+ # the pushed version.
+ # Here, we give PyPI time to propagate the package.
+ # We normalize the version (e.g., 2026.01.22 -> 2026.1.22) for PyPI lookup.
+ - name: Wait for PyPI propagation
+ uses: nick-fields/retry@v4
+ env:
+ NEW_TAG: ${{ needs.build.outputs.new_tag }}
+ with:
+ timeout_seconds: 10
+ max_attempts: 50
+ command: |
+ normalized_version=$(echo "$NEW_TAG" | sed -E 's/\.0+([0-9])/.\1/g')
+ curl -sf "https://pypi.org/pypi/vws-cli/${normalized_version}/json" > /dev/null
+
+ - name: Create requirements file
+ env:
+ NEW_TAG: ${{ needs.build.outputs.new_tag }}
+ run: echo "vws-cli==$NEW_TAG" > requirements.txt
+
+ - name: Create macOS binary for Vuforia Cloud Reco
+ uses: sayyid5416/pyinstaller@v1
+ with:
+ python_ver: '3.14'
+ pyinstaller_ver: ==6.12.0
+ spec: bin/vuforia-cloud-reco.py
+ requirements: requirements.txt
+ options: --onefile, --name "vuforia-cloud-reco-macos"
+ upload_exe_with_name: vuforia-cloud-reco-macos
+ clean_checkout: false
+
+ - name: Create macOS binary for Vuforia Web Services
+ uses: sayyid5416/pyinstaller@v1
+ with:
+ python_ver: '3.14'
+ pyinstaller_ver: ==6.12.0
+ spec: bin/vuforia-web-services.py
+ requirements: requirements.txt
+ options: --onefile, --name "vws-macos"
+ upload_exe_with_name: vws-macos
+ clean_checkout: false
+
+ - name: Create macOS binary for VuMark generation
+ uses: sayyid5416/pyinstaller@v1
+ with:
+ python_ver: '3.14'
+ pyinstaller_ver: ==6.12.0
+ spec: bin/vumark.py
+ requirements: requirements.txt
+ options: --onefile, --name "vumark-macos"
+ upload_exe_with_name: vumark-macos
+ clean_checkout: false
+
+ - name: Upload macOS binaries to release
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NEW_TAG: ${{ needs.build.outputs.new_tag }}
+ run: |-
+ gh release upload "$NEW_TAG" dist/vws-macos --clobber
+ gh release upload "$NEW_TAG" dist/vuforia-cloud-reco-macos --clobber
+ gh release upload "$NEW_TAG" dist/vumark-macos --clobber
+
+ publish-to-winget:
+ name: Publish to WinGet
+ needs: [build, build-windows]
+ runs-on: windows-latest
+ environment: winget
+ permissions:
+ contents: read
+
+ steps:
+ - uses: vedantmgoyal9/winget-releaser@v2
+ with:
+ identifier: VWSPython.vws-cli
+ version: ${{ needs.build.outputs.new_tag }}
+ release-tag: ${{ needs.build.outputs.new_tag }}
+ installers-regex: ^vws-windows\.exe$
+ token: ${{ secrets.WINGET_TOKEN }}
+
+ - uses: vedantmgoyal9/winget-releaser@v2
+ with:
+ identifier: VWSPython.vuforia-cloud-reco
+ version: ${{ needs.build.outputs.new_tag }}
+ release-tag: ${{ needs.build.outputs.new_tag }}
+ installers-regex: ^vuforia-cloud-reco-windows\.exe$
+ token: ${{ secrets.WINGET_TOKEN }}
+
+ - uses: vedantmgoyal9/winget-releaser@v2
+ with:
+ identifier: VWSPython.vumark
+ version: ${{ needs.build.outputs.new_tag }}
+ release-tag: ${{ needs.build.outputs.new_tag }}
+ installers-regex: ^vumark-windows\.exe$
+ token: ${{ secrets.WINGET_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 34c8a1e8..f698e90d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -110,4 +110,8 @@ src/*/_setuptools_scm_version.py
.DS_Store
**/.DS_Store
-uv.lock
+# Nix
+result
+
+# Vale styles downloaded by ``vale sync``
+styles/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 547ebb66..7c2f3423 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,99 +1,112 @@
---
fail_fast: true
-# We use system Python, with required dependencies specified in pyproject.toml.
-# We therefore cannot use those dependencies in pre-commit CI.
-ci:
- skip:
- - actionlint
- - check-manifest
- - deptry
- - doc8
- - docformatter
- - docs
- - interrogate
- - interrogate-docs
- - linkcheck
- - mypy
- - mypy-docs
- - pylint
- - pyproject-fmt-fix
- - pyright
- - pyright-docs
- - pyright-verifytypes
- - pyroma
- - ruff-check-fix
- - ruff-check-fix-docs
- - ruff-format-fix
- - ruff-format-fix-docs
- - shellcheck
- - shellcheck-docs
- - shfmt
- - shfmt-docs
- - spelling
- - sphinx-lint
- - vulture
- - vulture-docs
- - yamlfix
+.uv_version: &uv_version uv==0.11.7
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
-default_install_hook_types: [pre-commit, pre-push, commit-msg]
+default_install_hook_types: [pre-commit, pre-push]
repos:
+ - repo: https://github.com/NixOS/nixfmt
+ rev: v1.4.0
+ hooks:
+ - id: nixfmt
+ stages: [manual]
- repo: meta
hooks:
- id: check-useless-excludes
+ stages: [pre-commit]
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v5.0.0
+ rev: v6.0.0
hooks:
- id: check-added-large-files
+ stages: [pre-commit]
- id: check-case-conflict
+ stages: [pre-commit]
- id: check-executables-have-shebangs
+ stages: [pre-commit]
- id: check-merge-conflict
+ stages: [pre-commit]
- id: check-shebang-scripts-are-executable
+ stages: [pre-commit]
- id: check-symlinks
+ stages: [pre-commit]
- id: check-json
+ stages: [pre-commit]
- id: check-toml
+ stages: [pre-commit]
- id: check-vcs-permalinks
+ stages: [pre-commit]
- id: check-yaml
+ stages: [pre-commit]
- id: end-of-file-fixer
+ stages: [pre-commit]
- id: file-contents-sorter
files: spelling_private_dict\.txt$
+ stages: [pre-commit]
- id: trailing-whitespace
+ stages: [pre-commit]
- repo: https://github.com/pre-commit/pygrep-hooks
rev: v1.10.0
hooks:
- id: rst-directive-colons
+ stages: [pre-commit]
- id: rst-inline-touching-normal
+ stages: [pre-commit]
- id: text-unicode-replacement-char
+ stages: [pre-commit]
- id: rst-backticks
+ stages: [pre-commit]
- repo: local
hooks:
+ - id: uv-lock
+ name: uv lock
+ entry: uv lock --locked
+ language: python
+ pass_filenames: false
+ files: (^pyproject\.toml$|^uv\.lock$)
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
+
- id: actionlint
name: actionlint
entry: uv run --extra=dev actionlint
language: python
pass_filenames: false
types_or: [yaml]
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
- - id: docformatter
- name: docformatter
- entry: uv run --extra=dev -m docformatter --in-place
+ - id: hadolint
+ name: hadolint
+ entry: uv run --extra=dev hadolint
+ language: python
+ files: Dockerfile
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
+
+ - id: pydocstringformatter
+ name: pydocstringformatter
+ entry: uv run --extra=dev pydocstringformatter
language: python
types_or: [python]
- # We exclude this file to avoid https://github.com/PyCQA/docformatter/issues/291.
- exclude: src/vws_cli/commands.py
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
- id: shellcheck
name: shellcheck
entry: uv run --extra=dev shellcheck --shell=bash
language: python
types_or: [shell]
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
- id: shellcheck-docs
name: shellcheck-docs
@@ -101,15 +114,19 @@ repos:
--command="shellcheck --shell=bash"
language: python
types_or: [markdown, rst]
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
- id: shfmt
name: shfmt
- entry: shfmt --write --space-redirects --indent=4
+ entry: uv run --extra=dev shfmt --write --space-redirects --indent=4
language: python
pass_filenames: false
types_or: [shell]
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
- id: shfmt-docs
name: shfmt-docs
@@ -117,23 +134,29 @@ repos:
--no-pad-file --command="shfmt --write --space-redirects --indent=4"
language: python
types_or: [markdown, rst]
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
- id: mypy
name: mypy
stages: [pre-push]
- entry: uv run --extra=dev -m mypy
+ entry: uv run --extra=dev -m mypy --num-workers=4
language: python
types_or: [python, toml]
pass_filenames: false
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
- id: mypy-docs
name: mypy-docs
stages: [pre-push]
- entry: uv run --extra=dev doccmd --language=python --command="mypy"
+ entry: uv run --extra=dev doccmd --no-write-to-file --language=python --command="mypy
+ --num-workers=4"
language: python
types_or: [markdown, rst]
+ additional_dependencies:
+ - *uv_version
- id: check-manifest
name: check-manifest
@@ -141,7 +164,8 @@ repos:
entry: uv run --extra=dev -m check_manifest
language: python
pass_filenames: false
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
- id: pyright
name: pyright
@@ -150,12 +174,14 @@ repos:
language: python
types_or: [python, toml]
pass_filenames: false
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
- id: pyright-docs
name: pyright-docs
stages: [pre-push]
- entry: uv run --extra=dev doccmd --language=python --command="pyright"
+ entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python
+ --command="pyright"
language: python
types_or: [markdown, rst]
@@ -165,15 +191,20 @@ repos:
language: python
types_or: [python]
pass_filenames: false
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
- id: vulture-docs
name: vulture docs
- entry: uv run --extra=dev doccmd --language=python --command="vulture"
+ entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python
+ --command="vulture"
language: python
types_or: [python]
pass_filenames: false
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
- id: pyroma
name: pyroma
@@ -181,14 +212,18 @@ repos:
language: python
pass_filenames: false
types_or: [toml]
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
- id: deptry
name: deptry
entry: uv run --extra=dev -m deptry src/
language: python
pass_filenames: false
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
- id: pylint
name: pylint
@@ -196,11 +231,13 @@ repos:
language: python
stages: [manual]
pass_filenames: false
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
- id: pylint-docs
name: pylint-docs
- entry: uv run --extra=dev doccmd --language=python --command="pylint"
+ entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python
+ --command="pylint"
language: python
stages: [manual]
types_or: [markdown, rst]
@@ -210,21 +247,27 @@ repos:
entry: uv run --extra=dev -m ruff check --fix
language: python
types_or: [python]
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
- id: ruff-check-fix-docs
name: Ruff check fix docs
entry: uv run --extra=dev doccmd --language=python --command="ruff check --fix"
language: python
types_or: [markdown, rst]
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
- id: ruff-format-fix
name: Ruff format
entry: uv run --extra=dev -m ruff format
language: python
types_or: [python]
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
- id: ruff-format-fix-docs
name: Ruff format docs
@@ -232,21 +275,78 @@ repos:
format"
language: python
types_or: [markdown, rst]
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
+
+ - id: strict-kwargs-fix
+ name: strict-kwargs
+ entry: uv run --extra=dev strict-kwargs check --fix
+ language: python
+ types_or: [python]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
+ require_serial: true
+
+ - id: no-defaults
+ name: no-defaults
+ entry: uv run --extra=dev no-defaults
+ language: python
+ types_or: [python]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
+ require_serial: true
- id: interrogate-docs
name: interrogate docs
- entry: uv run --extra=dev doccmd --language=python --command="interrogate"
+ entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python
+ --command="interrogate"
language: python
types_or: [markdown, rst]
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
+
+ # Vale enforces prose style rules, such as banning em dashes, in
+ # reStructuredText and Markdown files.
+ # The rules come from the ``ai-tells`` package pinned in ``.vale.ini``,
+ # which ``vale sync`` downloads into the (gitignored) ``styles``
+ # directory.
+ # Vale needs ``rst2html`` from Docutils on the ``PATH`` to parse
+ # reStructuredText.
+ # ``vale sync`` also runs when ``.vale.ini`` changes, so a package
+ # bump takes effect without waiting for the next reStructuredText
+ # change.
+ - id: vale-sync
+ name: vale sync
+ entry: uv run --extra=dev vale sync
+ language: python
+ pass_filenames: false
+ files: (\.(rst|md)$|^\.vale\.ini$)
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
+
+ - id: vale
+ name: vale
+ entry: uv run --extra=dev vale
+ language: python
+ types_or: [rst, markdown]
+ require_serial: true
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
- id: doc8
name: doc8
entry: uv run --extra=dev -m doc8
language: python
types_or: [rst]
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
- id: interrogate
name: interrogate
@@ -254,6 +354,7 @@ repos:
language: python
types_or: [python]
exclude_types: [executable]
+ stages: [pre-commit]
- id: pyproject-fmt-fix
name: pyproject-fmt
@@ -262,31 +363,37 @@ repos:
types_or: [toml]
files: pyproject.toml
+ stages: [pre-commit]
- id: linkcheck
name: linkcheck
- entry: make -C docs/ linkcheck SPHINXOPTS=-W
+ entry: uv run --extra=dev sphinx-build -M linkcheck docs/source docs/build
+ -W
language: python
types_or: [rst]
stages: [manual]
pass_filenames: false
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
- id: spelling
name: spelling
- entry: make -C docs/ spelling SPHINXOPTS=-W
+ entry: uv run --extra=dev sphinx-build -M spelling docs/source docs/build
+ -W
language: python
types_or: [rst]
stages: [manual]
pass_filenames: false
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
- id: docs
name: Build Documentation
- entry: make docs
+ entry: uv run --extra=dev sphinx-build -M html docs/source docs/build
language: python
stages: [manual]
pass_filenames: false
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
- id: pyright-verifytypes
name: pyright-verifytypes
@@ -296,16 +403,70 @@ repos:
pass_filenames: false
types_or: [python]
+ - id: ty
+ name: ty
+ stages: [pre-push]
+ entry: uv run --extra=dev ty check
+ language: python
+ types_or: [python, toml]
+ pass_filenames: false
+ additional_dependencies:
+ - *uv_version
+
+ - id: ty-docs
+ name: ty-docs
+ stages: [pre-push]
+ entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python
+ --command="ty check"
+ language: python
+ types_or: [markdown, rst]
+ additional_dependencies:
+ - *uv_version
+
- id: yamlfix
name: yamlfix
entry: uv run --extra=dev yamlfix
language: python
types_or: [yaml]
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
+
+ - id: zizmor
+ name: zizmor
+ entry: uv run --extra=dev zizmor --strict-collection .github
+ language: python
+ pass_filenames: false
+ types_or: [yaml]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
- id: sphinx-lint
name: sphinx-lint
entry: uv run --extra=dev sphinx-lint --enable=all --disable=line-too-long
language: python
types_or: [rst]
- additional_dependencies: [uv==0.6.3]
+ additional_dependencies:
+ - *uv_version
+ stages: [pre-commit]
+
+ - id: pyrefly
+ name: pyrefly
+ stages: [pre-push]
+ entry: uv run --extra=dev pyrefly check
+ language: python
+ types_or: [python, toml]
+ pass_filenames: false
+ additional_dependencies:
+ - *uv_version
+
+ - id: pyrefly-docs
+ name: pyrefly-docs
+ stages: [pre-push]
+ entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python
+ --command="pyrefly check"
+ language: python
+ types_or: [markdown, rst]
+ additional_dependencies:
+ - *uv_version
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 00000000..4a36aae8
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,11 @@
+{
+ "overrides": [
+ {
+ "files": ["*.yaml", "*.yml"],
+ "options": {
+ "singleQuote": true,
+ "printWidth": 100
+ }
+ }
+ ]
+}
diff --git a/.vale.ini b/.vale.ini
new file mode 100644
index 00000000..729a5e88
--- /dev/null
+++ b/.vale.ini
@@ -0,0 +1,13 @@
+StylesPath = styles
+MinAlertLevel = error
+
+Packages = https://github.com/tbhb/vale-ai-tells/releases/download/v1.29.0/ai-tells.zip
+
+[*.{rst,md}]
+BasedOnStyles = ai-tells
+
+# These rules misclassify established technical, example, or release-note prose
+# in this repository. All other ai-tells rules remain enforced.
+ai-tells.FigurativeLands = NO
+ai-tells.FormalTransitions = NO
+ai-tells.VerbTricolon = NO
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 08484c48..3c6a7d06 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,8 +1,61 @@
Changelog
=========
-Next
-----
+.. towncrier release notes start
+
+2026.02.22
+----------
+
+
+2026.02.15.2
+------------
+
+
+2026.02.15.1
+------------
+
+
+2026.02.15
+----------
+
+
+2026.02.07.2
+------------
+
+
+2026.02.07.1
+------------
+
+
+2026.02.07
+----------
+
+
+* Add support for installing with winget on Windows.
+
+2026.01.25.1
+------------
+
+
+2026.01.25
+----------
+
+
+2026.01.22.3
+------------
+
+
+2026.01.22.2
+------------
+
+
+2026.01.22.1
+------------
+
+
+2026.01.22
+----------
+
2025.03.10
----------
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..f8ddc445
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,16 @@
+FROM python:3.14-slim
+
+LABEL org.opencontainers.image.source="https://github.com/VWS-Python/vws-cli"
+LABEL org.opencontainers.image.description="CLI for Vuforia Web Services"
+LABEL org.opencontainers.image.licenses="MIT"
+
+ARG VWS_CLI_VERSION
+
+# Install uv
+COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
+
+# Install the package from PyPI with pinned version
+RUN uv pip install --system --no-cache "vws-cli==${VWS_CLI_VERSION}"
+
+# Set up entrypoint - users can override with vuforia-cloud-reco
+ENTRYPOINT ["vws"]
diff --git a/LICENSE b/LICENSE
index a2809f19..c9f18d1a 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2019 Adam Dangoor
+Copyright (c) 2025 Adam Dangoor
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 216a2574..00000000
--- a/Makefile
+++ /dev/null
@@ -1,12 +0,0 @@
-SHELL := /bin/bash -euxo pipefail
-
-# Treat Sphinx warnings as errors
-SPHINXOPTS := -W
-
-.PHONY: docs
-docs:
- make -C docs clean html SPHINXOPTS=$(SPHINXOPTS)
-
-.PHONY: open-docs
-open-docs:
- python -c 'import os, webbrowser; webbrowser.open("file://" + os.path.abspath("docs/build/html/index.html"))'
diff --git a/README.rst b/README.rst
index b646e435..2e531d51 100644
--- a/README.rst
+++ b/README.rst
@@ -1,4 +1,4 @@
-|Build Status| |codecov| |PyPI|
+|Build Status| |PyPI|
vws-cli
=======
@@ -34,6 +34,59 @@ Requires `Homebrew`_.
.. _Homebrew: https://docs.brew.sh/Installation
+With Nix
+^^^^^^^^
+
+Requires `Nix`_.
+
+.. code-block:: console
+
+ $ nix --extra-experimental-features 'nix-command flakes' run "github:VWS-Python/vws-cli" -- --help
+
+To avoid passing ``--extra-experimental-features`` every time, `enable flakes`_ permanently.
+
+.. _Nix: https://nixos.org/download/
+.. _enable flakes: https://wiki.nixos.org/wiki/Flakes#Enabling_flakes_permanently
+
+Or add to your flake inputs:
+
+.. code-block:: nix
+
+ {
+ inputs.vws-cli.url = "github:VWS-Python/vws-cli";
+ }
+
+With Docker
+^^^^^^^^^^^
+
+.. code-block:: console
+
+ $ docker run --rm "ghcr.io/vws-python/vws-cli" --help
+
+To use ``vuforia-cloud-reco``:
+
+.. code-block:: console
+
+ $ docker run --rm --entrypoint vuforia-cloud-reco "ghcr.io/vws-python/vws-cli" --help
+
+To use ``vumark``:
+
+.. code-block:: console
+
+ $ docker run --rm --entrypoint vumark "ghcr.io/vws-python/vws-cli" --help
+
+With winget (Windows)
+^^^^^^^^^^^^^^^^^^^^^
+
+Requires `winget`_.
+
+.. code-block:: console
+
+ $ winget install --id VWSPython.vws-cli --source winget --exact
+ $ winget install --id VWSPython.vuforia-cloud-reco --source winget --exact
+
+.. _winget: https://docs.microsoft.com/windows/package-manager/winget/
+
Pre-built Linux binaries
^^^^^^^^^^^^^^^^^^^^^^^^
@@ -41,6 +94,23 @@ See the `full documentation`_ for details on how to install pre-built Linux bina
.. _full documentation: https://vws-python.github.io/vws-cli/install.html#pre-built-linux-x86-binaries
+Pre-built Windows binaries
+^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Download the Windows executables from the `latest release`_ and place them in a directory on your ``PATH``.
+The filenames are:
+
+* ``vws-windows.exe``
+* ``vuforia-cloud-reco-windows.exe``
+* ``vumark-windows.exe``
+
+.. _latest release: https://github.com/VWS-Python/vws-cli/releases/latest
+
+Pre-built macOS (ARM) binaries
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+See the `full documentation`_ for details on how to install pre-built macOS binaries.
+
Usage example
-------------
@@ -78,8 +148,6 @@ See the `full documentation `__ for infor
.. |Build Status| image:: https://github.com/VWS-Python/vws-cli/actions/workflows/ci.yml/badge.svg?branch=main
:target: https://github.com/VWS-Python/vws-cli/actions
-.. |codecov| image:: https://codecov.io/gh/VWS-Python/vws-cli/branch/main/graph/badge.svg
- :target: https://codecov.io/gh/VWS-Python/vws-cli
.. |PyPI| image:: https://badge.fury.io/py/VWS-CLI.svg
:target: https://badge.fury.io/py/VWS-CLI
-.. |minimum-python-version| replace:: 3.13
+.. |minimum-python-version| replace:: 3.14
diff --git a/VERSION b/VERSION
new file mode 100644
index 00000000..bd12d807
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+2026.02.22
diff --git a/bin/vuforia-cloud-reco.py b/bin/vuforia-cloud-reco.py
index 55438101..22d5bce7 100755
--- a/bin/vuforia-cloud-reco.py
+++ b/bin/vuforia-cloud-reco.py
@@ -1,8 +1,6 @@
#!/usr/bin/env python3
-"""
-Run VWS Cloud Reco CLI.
-"""
+"""Run VWS Cloud Reco CLI."""
from vws_cli.query import vuforia_cloud_reco
diff --git a/bin/vuforia-web-services.py b/bin/vuforia-web-services.py
index 90d42003..fc86cecc 100755
--- a/bin/vuforia-web-services.py
+++ b/bin/vuforia-web-services.py
@@ -1,8 +1,6 @@
#!/usr/bin/env python3
-"""
-Run VWS CLI.
-"""
+"""Run VWS CLI."""
from vws_cli import vws_group
diff --git a/bin/vumark.py b/bin/vumark.py
new file mode 100755
index 00000000..9f1ee02d
--- /dev/null
+++ b/bin/vumark.py
@@ -0,0 +1,7 @@
+#!/usr/bin/env python3
+
+"""Run VuMark generation CLI."""
+
+from vws_cli.vumark import generate_vumark
+
+generate_vumark()
diff --git a/codecov.yaml b/codecov.yaml
deleted file mode 100644
index 5c35baac..00000000
--- a/codecov.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-coverage:
- status:
- patch:
- default:
- # Require 100% test coverage.
- target: 100%
diff --git a/docs/Makefile b/docs/Makefile
deleted file mode 100644
index 7aba47ed..00000000
--- a/docs/Makefile
+++ /dev/null
@@ -1,19 +0,0 @@
-# Minimal makefile for Sphinx documentation
-#
-
-# You can set these variables from the command line.
-SPHINXOPTS =
-SPHINXBUILD = sphinx-build
-SOURCEDIR = source
-BUILDDIR = build
-
-# Put it first so that "make" without argument is like "make help".
-help:
- @uv run --extra=dev $(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
-
-.PHONY: help Makefile
-
-# Catch-all target: route all unknown targets to Sphinx using the new
-# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
-%: Makefile
- @uv run --extra=dev $(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/source/__init__.py b/docs/source/__init__.py
index b63eed5f..535ceb2e 100644
--- a/docs/source/__init__.py
+++ b/docs/source/__init__.py
@@ -1,3 +1 @@
-"""
-Documentation.
-"""
+"""Documentation."""
diff --git a/docs/source/commands.rst b/docs/source/commands.rst
index 567b5ffd..27cc816d 100644
--- a/docs/source/commands.rst
+++ b/docs/source/commands.rst
@@ -8,3 +8,6 @@ Commands
.. click:: vws_cli.query:vuforia_cloud_reco
:prog: vuforia-cloud-reco
:show-nested:
+
+.. click:: vws_cli.vumark:generate_vumark
+ :prog: vumark
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 84a07124..9b7fa2bb 100755
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -1,7 +1,5 @@
#!/usr/bin/env python3
-"""
-Configuration for Sphinx.
-"""
+"""Configuration for Sphinx."""
# pylint: disable=invalid-name
@@ -24,11 +22,20 @@
extensions = [
"sphinx_copybutton",
"sphinxcontrib.spelling",
+ "sphinxcontrib.towncrier.ext",
"sphinx_click.ext",
"sphinx_inline_tabs",
"sphinx_substitution_extensions",
]
+# Render the unreleased ``newsfragments/`` entries into
+# ``docs/source/unreleased.rst`` so the Sphinx spelling, doc-build and
+# link-checking gates cover the prose before it is assembled into
+# CHANGELOG.rst at release time.
+towncrier_draft_autoversion_mode = "draft"
+towncrier_draft_include_empty = True
+towncrier_draft_working_directory = f"{_pyproject_file.parent}"
+
templates_path = ["_templates"]
source_suffix = ".rst"
master_doc = "index"
@@ -99,6 +106,9 @@
html_show_sourcelink = False
html_theme_options = {
"sidebar_hide_name": False,
+ "source_repository": "https://github.com/VWS-Python/vws-cli/",
+ "source_branch": "main",
+ "source_directory": "docs/source/",
}
# Retry link checking to avoid transient network errors.
@@ -119,4 +129,5 @@
.. |minimum-python-version| replace:: {minimum_python_version}
.. |github-owner| replace:: VWS-Python
.. |github-repository| replace:: vws-cli
+.. |docker-image| replace:: ghcr.io/vws-python/vws-cli
"""
diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst
index 21e5dbe8..ee4b19a7 100644
--- a/docs/source/contributing.rst
+++ b/docs/source/contributing.rst
@@ -31,7 +31,7 @@ Install ``pre-commit`` hooks:
.. code-block:: console
- $ pre-commit install
+ $ prek install
Linting
-------
@@ -40,9 +40,9 @@ Run lint tools either by committing, or with:
.. code-block:: console
- $ pre-commit run --all-files --hook-stage pre-commit --verbose
- $ pre-commit run --all-files --hook-stage pre-push --verbose
- $ pre-commit run --all-files --hook-stage manual --verbose
+ $ prek run --all-files --hook-stage pre-commit --verbose
+ $ prek run --all-files --hook-stage pre-push --verbose
+ $ prek run --all-files --hook-stage manual --verbose
.. _Homebrew: https://brew.sh
@@ -64,8 +64,8 @@ Run the following commands to build and view documentation locally:
.. code-block:: console
- $ make docs
- $ make open-docs
+ $ uv run --extra=dev sphinx-build -M html docs/source docs/build -W
+ $ python -c 'import os, webbrowser; webbrowser.open("file://" + os.path.abspath("docs/build/html/index.html"))'
Continuous integration
----------------------
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 25754db6..78fe3f2c 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -20,4 +20,5 @@ Reference
commands
contributing
release-process
+ unreleased
changelog
diff --git a/docs/source/install.rst b/docs/source/install.rst
index 1273db81..44de4ec1 100644
--- a/docs/source/install.rst
+++ b/docs/source/install.rst
@@ -22,6 +22,64 @@ Requires `Homebrew`_.
.. _Homebrew: https://docs.brew.sh/Installation
+With Nix
+~~~~~~~~
+
+Requires Nix_.
+
+.. code-block:: console
+ :substitutions:
+
+ $ nix --extra-experimental-features 'nix-command flakes' develop "github:|github-owner|/|github-repository|"
+
+To avoid passing ``--extra-experimental-features`` every time, `enable flakes`_ permanently.
+
+.. _Nix: https://nixos.org/download/
+.. _enable flakes: https://wiki.nixos.org/wiki/Flakes#Enabling_flakes_permanently
+
+Or add to your flake inputs:
+
+.. code-block:: nix
+ :substitutions:
+
+ {
+ inputs.vws-cli.url = "github:|github-owner|/|github-repository|";
+ }
+
+With Docker
+~~~~~~~~~~~
+
+.. code-block:: console
+ :substitutions:
+
+ $ docker run --rm "|docker-image|" --help
+
+To use ``vuforia-cloud-reco``:
+
+.. code-block:: console
+ :substitutions:
+
+ $ docker run --rm --entrypoint vuforia-cloud-reco "|docker-image|" --help
+
+To use ``vumark``:
+
+.. code-block:: console
+ :substitutions:
+
+ $ docker run --rm --entrypoint vumark "|docker-image|" --help
+
+With winget (Windows)
+~~~~~~~~~~~~~~~~~~~~~
+
+Requires winget_.
+
+.. code-block:: console
+
+ $ winget install --id VWSPython.vws-cli --source winget --exact
+ $ winget install --id VWSPython.vuforia-cloud-reco --source winget --exact
+
+.. _winget: https://docs.microsoft.com/windows/package-manager/winget/
+
Pre-built Linux (x86) binaries
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -32,6 +90,41 @@ Pre-built Linux (x86) binaries
chmod +x /usr/local/bin/vws
$ curl --fail -L "https://github.com/|github-owner|/|github-repository|/releases/download/|release|/vuforia-cloud-reco-linux" -o /usr/local/bin/vuforia-cloud-reco &&
chmod +x /usr/local/bin/vuforia-cloud-reco
+ $ curl --fail -L "https://github.com/|github-owner|/|github-repository|/releases/download/|release|/vumark-linux" -o /usr/local/bin/vumark &&
+ chmod +x /usr/local/bin/vumark
+
+Pre-built Windows binaries
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Download the Windows executables from the `latest release`_ and place them in a directory on your ``PATH``.
+The filenames are:
+
+* ``vws-windows.exe``
+* ``vuforia-cloud-reco-windows.exe``
+* ``vumark-windows.exe``
+
+.. _latest release: https://github.com/VWS-Python/vws-cli/releases/latest
+
+Pre-built macOS (ARM) binaries
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. code-block:: console
+ :substitutions:
+
+ $ curl --fail -L "https://github.com/|github-owner|/|github-repository|/releases/download/|release|/vws-macos" -o /usr/local/bin/vws &&
+ chmod +x /usr/local/bin/vws
+ $ curl --fail -L "https://github.com/|github-owner|/|github-repository|/releases/download/|release|/vuforia-cloud-reco-macos" -o /usr/local/bin/vuforia-cloud-reco &&
+ chmod +x /usr/local/bin/vuforia-cloud-reco
+ $ curl --fail -L "https://github.com/|github-owner|/|github-repository|/releases/download/|release|/vumark-macos" -o /usr/local/bin/vumark &&
+ chmod +x /usr/local/bin/vumark
+
+You may need to remove the quarantine attribute to allow the binaries to run:
+
+.. code-block:: console
+
+ $ xattr -d com.apple.quarantine /usr/local/bin/vws
+ $ xattr -d com.apple.quarantine /usr/local/bin/vuforia-cloud-reco
+ $ xattr -d com.apple.quarantine /usr/local/bin/vumark
Shell completion
~~~~~~~~~~~~~~~~
diff --git a/docs/source/release-process.rst b/docs/source/release-process.rst
index bfeb5899..22ce9df6 100644
--- a/docs/source/release-process.rst
+++ b/docs/source/release-process.rst
@@ -7,6 +7,10 @@ Outcomes
* A new ``git`` tag available to install.
* A new package on PyPI.
* A new Homebrew recipe available to install.
+* A new Docker image on GitHub Container Registry.
+* New binary assets attached to the GitHub release.
+* New Winget packages available to install for ``vws``,
+ ``vuforia-cloud-reco``, and ``vumark``.
Perform a Release
~~~~~~~~~~~~~~~~~
diff --git a/docs/source/unreleased.rst b/docs/source/unreleased.rst
new file mode 100644
index 00000000..22ac7472
--- /dev/null
+++ b/docs/source/unreleased.rst
@@ -0,0 +1,8 @@
+Unreleased changes
+==================
+
+Changes that have landed on the main branch but are not yet part of a
+tagged release. These entries are assembled into the
+:doc:`changelog` when the next release is published.
+
+.. towncrier-draft-entries::
diff --git a/docs/source/usage-example.rst b/docs/source/usage-example.rst
index 0f8faaa5..2f3dd790 100644
--- a/docs/source/usage-example.rst
+++ b/docs/source/usage-example.rst
@@ -1,7 +1,7 @@
Usage example
-------------
-.. skip doccmd[shellcheck]: next
+.. skip doccmd[shellcheck]: start
.. code-block:: console
@@ -22,3 +22,74 @@ Usage example
- target_id: b60f60121d37418eb1de123c381b2af9
- target_id: e3a6e1a216ad4df3aaae1f6dd309c800
$
+
+.. skip doccmd[shellcheck]: end
+
+Model Target datasets
+~~~~~~~~~~~~~~~~~~~~~
+
+Model Target dataset commands use OAuth2 credentials for the `Model Target Web API`_, rather than server keys.
+Vuforia generates a dataset in the background, so create the dataset, wait for it, and then download it.
+
+.. _Model Target Web API: https://developer.vuforia.com/library/vuforia-engine/web-api/model-target-web-api/
+
+.. skip doccmd[shellcheck]: start
+
+.. code-block:: console
+
+ $ vws create-model-target-dataset \
+ --client-id "$MODEL_TARGET_CLIENT_ID" \
+ --client-secret "$MODEL_TARGET_CLIENT_SECRET" \
+ --name my_dataset_name \
+ --target-sdk 10.29 \
+ --model-name my_model_name \
+ --cad-data-file ~/Documents/my_model.obj \
+ --cad-data-format obj
+ 1f0f2b7c1f0f4b0a9a1c4b6b0a5b0f2b
+ $ vws wait-for-model-target-dataset-generated \
+ --client-id "$MODEL_TARGET_CLIENT_ID" \
+ --client-secret "$MODEL_TARGET_CLIENT_SECRET" \
+ --dataset-uuid 1f0f2b7c1f0f4b0a9a1c4b6b0a5b0f2b
+ completed_at: '2026-08-14 12:00:00+00:00'
+ created_at: '2026-08-14 11:59:00+00:00'
+ dataset_uuid: 1f0f2b7c1f0f4b0a9a1c4b6b0a5b0f2b
+ error: null
+ eta: null
+ status: done
+ warning: null
+ $ vws download-model-target-dataset \
+ --client-id "$MODEL_TARGET_CLIENT_ID" \
+ --client-secret "$MODEL_TARGET_CLIENT_SECRET" \
+ --dataset-uuid 1f0f2b7c1f0f4b0a9a1c4b6b0a5b0f2b \
+ --output ~/Documents/my_dataset.zip
+ $ vws delete-model-target-dataset \
+ --client-id "$MODEL_TARGET_CLIENT_ID" \
+ --client-secret "$MODEL_TARGET_CLIENT_SECRET" \
+ --dataset-uuid 1f0f2b7c1f0f4b0a9a1c4b6b0a5b0f2b
+ $
+
+.. skip doccmd[shellcheck]: end
+
+Use ``--dataset-type advanced`` with each of these commands for an advanced dataset.
+To give more than one model, or to give guide views, describe the models in a JSON file and give it with ``--models-file``:
+
+.. code-block:: json
+
+ {
+ "models": [
+ {
+ "name": "my_model_name",
+ "cadDataUrl": "https://example.com/my_model.zip",
+ "cadDataFormat": "OBJ",
+ "views": [
+ {
+ "name": "front",
+ "guideViewPosition": {
+ "rotation": [0, 0, 0, 1],
+ "translation": [0, 0, -1.5]
+ }
+ }
+ ]
+ }
+ ]
+ }
diff --git a/docs/towncrier_template.rst.jinja b/docs/towncrier_template.rst.jinja
new file mode 100644
index 00000000..6da87833
--- /dev/null
+++ b/docs/towncrier_template.rst.jinja
@@ -0,0 +1,14 @@
+
+{% for section_name, section in sections.items() %}
+{% if section %}
+{% for category, entries in section.items() %}
+{% for text, _ in entries.items() %}
+- {{ text }}
+
+{% endfor %}
+{% endfor %}
+{% else %}
+No significant changes.
+
+{% endif %}
+{% endfor %}
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 00000000..48ff2c41
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,99 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1768886240,
+ "narHash": "sha256-C2TjvwYZ2VDxYWeqvvJ5XPPp6U7H66zeJlRaErJKoEM=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "pyproject-build-systems": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ],
+ "pyproject-nix": [
+ "pyproject-nix"
+ ],
+ "uv2nix": [
+ "uv2nix"
+ ]
+ },
+ "locked": {
+ "lastModified": 1763662255,
+ "narHash": "sha256-4bocaOyLa3AfiS8KrWjZQYu+IAta05u3gYZzZ6zXbT0=",
+ "owner": "pyproject-nix",
+ "repo": "build-system-pkgs",
+ "rev": "042904167604c681a090c07eb6967b4dd4dae88c",
+ "type": "github"
+ },
+ "original": {
+ "owner": "pyproject-nix",
+ "repo": "build-system-pkgs",
+ "type": "github"
+ }
+ },
+ "pyproject-nix": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1768984832,
+ "narHash": "sha256-Q/5ICDALWlG+MbfLoghvXb3K9S0jqGt0mj8Y8RN+60k=",
+ "owner": "pyproject-nix",
+ "repo": "pyproject.nix",
+ "rev": "c372512cad744dbdeb65dba864376a0a5ab339a7",
+ "type": "github"
+ },
+ "original": {
+ "owner": "pyproject-nix",
+ "repo": "pyproject.nix",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs",
+ "pyproject-build-systems": "pyproject-build-systems",
+ "pyproject-nix": "pyproject-nix",
+ "uv2nix": "uv2nix"
+ }
+ },
+ "uv2nix": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ],
+ "pyproject-nix": [
+ "pyproject-nix"
+ ]
+ },
+ "locked": {
+ "lastModified": 1768526251,
+ "narHash": "sha256-uT0ICn7tknFtdOP7zhkgOkusq38EcttqUCS5Ho7nIvc=",
+ "owner": "pyproject-nix",
+ "repo": "uv2nix",
+ "rev": "1b8f4d4c874f909de24639142c1141c84119b0f0",
+ "type": "github"
+ },
+ "original": {
+ "owner": "pyproject-nix",
+ "repo": "uv2nix",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 00000000..fbc6c377
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,148 @@
+{
+ description = "A CLI for the Vuforia Web Services (VWS) API";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+
+ pyproject-nix = {
+ url = "github:pyproject-nix/pyproject.nix";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+
+ uv2nix = {
+ url = "github:pyproject-nix/uv2nix";
+ inputs.pyproject-nix.follows = "pyproject-nix";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+
+ pyproject-build-systems = {
+ url = "github:pyproject-nix/build-system-pkgs";
+ inputs.pyproject-nix.follows = "pyproject-nix";
+ inputs.uv2nix.follows = "uv2nix";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+ };
+
+ outputs =
+ {
+ self,
+ nixpkgs,
+ pyproject-nix,
+ uv2nix,
+ pyproject-build-systems,
+ ...
+ }:
+ let
+ inherit (nixpkgs) lib;
+ forAllSystems = lib.genAttrs lib.systems.flakeExposed;
+
+ workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; };
+
+ overlay = workspace.mkPyprojectOverlay {
+ sourcePreference = "wheel";
+ };
+
+ # Read version from VERSION file, fall back to commit hash for dev builds
+ version =
+ let
+ versionFile = ./VERSION;
+ in
+ if builtins.pathExists versionFile then
+ lib.strings.trim (builtins.readFile versionFile)
+ else
+ "0.0.0+${self.shortRev or self.dirtyShortRev or "unknown"}";
+
+ # Override vws-cli to set the version
+ vwsCliOverlay = final: prev: {
+ vws-cli = prev.vws-cli.overrideAttrs (old: {
+ env = (old.env or { }) // {
+ SETUPTOOLS_SCM_PRETEND_VERSION = version;
+ };
+ });
+ };
+
+ pythonSets = forAllSystems (
+ system:
+ let
+ pkgs = nixpkgs.legacyPackages.${system};
+ python = pkgs.python314;
+ in
+ (pkgs.callPackage pyproject-nix.build.packages {
+ inherit python;
+ }).overrideScope
+ (
+ lib.composeManyExtensions [
+ pyproject-build-systems.overlays.wheel
+ overlay
+ vwsCliOverlay
+ ]
+ )
+ );
+
+ in
+ {
+ packages = forAllSystems (
+ system:
+ let
+ pythonSet = pythonSets.${system};
+ virtualenv = pythonSet.mkVirtualEnv "vws-cli-env" workspace.deps.default;
+ in
+ {
+ default = virtualenv;
+ vws-cli = virtualenv;
+ }
+ );
+
+ apps = forAllSystems (system: {
+ default = {
+ type = "app";
+ program = "${self.packages.${system}.default}/bin/vws";
+ };
+ vws = {
+ type = "app";
+ program = "${self.packages.${system}.default}/bin/vws";
+ };
+ vuforia-cloud-reco = {
+ type = "app";
+ program = "${self.packages.${system}.default}/bin/vuforia-cloud-reco";
+ };
+ });
+
+ devShells = forAllSystems (
+ system:
+ let
+ pkgs = nixpkgs.legacyPackages.${system};
+ pythonSet = pythonSets.${system};
+ virtualenv = pythonSet.mkVirtualEnv "vws-cli-dev-env" workspace.deps.all;
+ in
+ {
+ default = pkgs.mkShell {
+ packages = [
+ virtualenv
+ pkgs.uv
+ ];
+ env = {
+ UV_NO_SYNC = "1";
+ UV_PYTHON = pythonSet.python.interpreter;
+ UV_PYTHON_DOWNLOADS = "never";
+ };
+ shellHook = ''
+ unset PYTHONPATH
+ '';
+ };
+ }
+ );
+
+ checks = forAllSystems (
+ system:
+ let
+ pkgs = nixpkgs.legacyPackages.${system};
+ in
+ {
+ vws-cli-smoke = pkgs.runCommand "vws-cli-smoke-test" { } ''
+ ${self.packages.${system}.default}/bin/vws --help > $out
+ '';
+ }
+ );
+ };
+}
diff --git a/newsfragments/.gitkeep b/newsfragments/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/newsfragments/2434.change.rst b/newsfragments/2434.change.rst
new file mode 100644
index 00000000..428a0e16
--- /dev/null
+++ b/newsfragments/2434.change.rst
@@ -0,0 +1 @@
+Drop Python 3.13 support, and update VWS Python so that the documented ``ProjectHasNoApiAccess`` result code spelling gives a friendly error message.
diff --git a/newsfragments/2440.change.rst b/newsfragments/2440.change.rst
new file mode 100644
index 00000000..3813ac27
--- /dev/null
+++ b/newsfragments/2440.change.rst
@@ -0,0 +1 @@
+Add Model Target dataset commands: ``vws create-model-target-dataset``, ``vws get-model-target-dataset-status``, ``vws wait-for-model-target-dataset-generated``, ``vws download-model-target-dataset`` and ``vws delete-model-target-dataset``.
diff --git a/newsfragments/2441.change.rst b/newsfragments/2441.change.rst
new file mode 100644
index 00000000..11c566cb
--- /dev/null
+++ b/newsfragments/2441.change.rst
@@ -0,0 +1 @@
+Add a ``vws get-database-reco-counts-report`` command, which requests a per-target recognition counts report for a month, waits for Vuforia to generate it, and writes the CSV to stdout or to a given path.
diff --git a/pyproject.toml b/pyproject.toml
index 6a861e38..577a36d7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,7 +3,6 @@ build-backend = "setuptools.build_meta"
requires = [
"setuptools",
"setuptools-scm>=8.1.0",
- "wheel",
]
[project]
@@ -15,112 +14,132 @@ keywords = [
"vuforia",
"vws",
]
-license = { file = "LICENSE" }
+license = "MIT"
authors = [
{ name = "Adam Dangoor", email = "adamdangoor@gmail.com" },
]
-requires-python = ">=3.13"
+requires-python = ">=3.14"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Web Environment",
- "License :: OSI Approved :: MIT License",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX",
"Programming Language :: Python :: 3 :: Only",
- "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
]
dynamic = [
"version",
]
dependencies = [
- "beartype==0.19.0",
- "click==8.1.8",
- "pyyaml==6.0.2",
- "vws-python==2025.3.10.1",
+ "beartype==0.22.9",
+ "click==8.4.2",
+ "pyyaml==6.0.3",
+ "vws-python==2026.8.14",
]
optional-dependencies.dev = [
- "actionlint-py==1.7.7.23",
- "check-manifest==0.50",
- "deptry==0.23.0",
- "doc8==1.1.2",
- "doccmd==2025.3.6",
- "docformatter==1.7.5",
- "freezegun==1.5.1",
- "furo==2024.8.6",
+ "actionlint-py==1.7.12.24",
+ "check-manifest==0.51",
+ "deptry==0.25.1",
+ "doc8==2.0.0",
+ "doccmd==2026.7.19",
+ "freezegun==1.5.5",
+ "furo==2025.12.19",
+ "hadolint-bin==2.15.1; sys_platform!='win32'",
"interrogate==1.7.0",
- "mypy[faster-cache]==1.15.0",
- "mypy-strict-kwargs==2024.12.25",
- "pre-commit==4.1.0",
- "pylint==3.3.4",
- "pyproject-fmt==2.5.1",
- "pyright==1.1.393",
- "pyroma==4.2",
- "pytest==8.3.5",
- "pytest-cov==6.0.0",
- "pytest-regressions==2.7.0",
- "ruff==0.10.0",
- "setuptools-scm==8.2.0",
+ "mypy[faster-cache]==2.3.0",
+ "mypy-strict-kwargs==2026.7.19.1",
+ "no-defaults==2.1.0",
+ "prek==0.4.13",
+ "pydocstringformatter==1.0.0",
+ "pylint[spelling]==4.0.7",
+ "pyproject-fmt==2.27.0",
+ "pyrefly==1.2.0",
+ "pyright==1.1.411",
+ "pyroma==5.0.1",
+ "pytest==9.1.1",
+ "pytest-beartype-tests==2026.4.26",
+ "pytest-cov==7.1.0",
+ "pytest-regressions==2.11.0",
+ "ruff==0.16.2",
+ "setuptools-scm==10.2.1",
# We add shellcheck-py not only for shell scripts and shell code blocks,
# but also because having it installed means that ``actionlint-py`` will
# use it to lint shell commands in GitHub workflow files.
- "shellcheck-py==0.10.0.1",
- "shfmt-py==3.11.0.2",
- "sphinx==8.2.3",
- "sphinx-click==6.0.0",
+ "shellcheck-py==0.11.0.1",
+ "shfmt-py==4.0.0",
+ "sphinx==9.1.0",
+ "sphinx-click==6.2.0",
"sphinx-copybutton==0.5.2",
- "sphinx-inline-tabs==2023.4.21",
- "sphinx-lint==1.0.0",
+ "sphinx-inline-tabs==2025.12.21.14",
+ "sphinx-lint==1.0.2",
"sphinx-pyproject==0.3.0",
- "sphinx-substitution-extensions==2025.3.3",
- "sphinxcontrib-spelling==8.0.1",
- "types-pyyaml==6.0.12.20241230",
- "vulture==2.14",
- "vws-python-mock==2025.3.10.1",
+ "sphinx-substitution-extensions==2026.8.5",
+ "sphinxcontrib-spelling==8.0.2",
+ # ``sphinxcontrib-towncrier`` renders unreleased news fragments
+ # into docs/source/unreleased.rst during Sphinx builds.
+ "sphinxcontrib-towncrier==0.5.0a0",
+ # Listed explicitly (despite being transitive via vws-python-mock) so that
+ # [tool.uv.sources] can redirect to the CPU-only PyTorch index.
+ # See: https://vws-python.github.io/vws-python-mock/installation.html#faster-installation
+ "strict-kwargs==2026.7.24",
+ "torch>=2.5.1",
+ "torchvision>=0.20.1",
+ "towncrier==25.8.0",
+ "ty==0.0.70",
+ "types-pyyaml==6.0.12.20260724",
+ "vale==3.13.0.0",
+ "vulture==2.16",
+ "vws-python-mock==2026.8.14",
"vws-test-fixtures==2023.3.5",
- "yamlfix==1.17.0",
-]
-optional-dependencies.packaging = [
- "pyinstaller==6.12.0",
+ "yamlfix==1.19.1",
+ "zizmor==1.29.0",
]
optional-dependencies.release = [
- "check-wheel-contents==0.6.1",
+ "check-wheel-contents==0.6.3",
"homebrew-pypi-poet==0.10.0",
+ "towncrier==25.8.0",
]
urls.Documentation = "https://vws-python.github.io/vws-cli/"
urls.Source = "https://github.com/VWS-Python/vws-cli"
scripts.vuforia-cloud-reco = "vws_cli.query:vuforia_cloud_reco"
+scripts.vumark = "vws_cli.vumark:generate_vumark"
scripts.vws = "vws_cli:vws_group"
-[tool.setuptools]
-zip-safe = false
+[dependency-groups]
+dev = []
-[tool.setuptools.packages.find]
-where = [
+[tool.setuptools]
+packages.find.where = [
"src",
]
-
-[tool.setuptools.package-data]
-vws_cli = [
+package-data.vws_cli = [
"py.typed",
]
+zip-safe = false
[tool.setuptools_scm]
# We write the version to a file so that we can import it.
# We choose a ``.py`` file so that we can read it without
# worrying about including the file in MANIFEST.in.
-write_to = "src/vws_cli/_setuptools_scm_version.py"
+version_file = "src/vws_cli/_setuptools_scm_version.py"
+
+[tool.uv]
+sources.torch = { index = "pytorch-cpu" }
+sources.torchvision = { index = "pytorch-cpu" }
+index = [ { name = "pytorch-cpu", url = "https://download.pytorch.org/whl/cpu", explicit = true } ]
[tool.ruff]
line-length = 79
-
lint.select = [
"ALL",
]
lint.ignore = [
# Ruff warns that this conflicts with the formatter.
"COM812",
- # Allow our chosen docstring line-style - no one-line summary.
- "D200",
+ # Copyright headers are not required.
+ "CPY001",
+ # Allow our chosen docstring line-style - pydocstringformatter handles formatting
+ # but doesn't enforce D205 (blank line after summary) or D212 (summary on first line).
"D205",
"D212",
# Allow backslashes in a docstring.
@@ -130,85 +149,41 @@ lint.ignore = [
"E501",
# Ruff warns that this conflicts with the formatter.
"ISC001",
- # Ignore "too-many-*" errors as they seem to get in the way more than
+ # Ignore 'too-many-*' errors as they seem to get in the way more than
# helping.
"PLR0913",
# Allow 'assert' as we use it for tests and type narrowing.
"S101",
+ # On Python 3.14, annotations are lazily evaluated (PEP 649), so Ruff
+ # suggests moving annotation-only imports into type-checking blocks.
+ # ``beartype`` resolves annotations at runtime, in source code and in
+ # tests, so those imports must be available at runtime.
+ "TC001",
+ "TC002",
+ "TC003",
+]
+lint.per-file-ignores."doccmd_*.py" = [
+ # Allow our chosen docstring line-style - pydocstringformatter handles
+ # formatting but docstrings in docs may not match this style.
+ "D200",
+ # Allow asserts in docs.
+ "S101",
]
-
lint.per-file-ignores."tests/test_*.py" = [
# Do not require tests to have a one-line summary.
"D205",
]
-
# Do not automatically remove commented out code.
# We comment out code during development, and with VSCode auto-save, this code
# is sometimes annoyingly removed.
lint.unfixable = [
"ERA001",
]
+lint.external = [ "NOD" ]
+lint.flake8-tidy-imports.banned-api."typing.cast".msg = "typing.cast is banned: use explicit type narrowing or a typed variable instead."
lint.pydocstyle.convention = "google"
[tool.pylint]
-
-[tool.pylint.'MASTER']
-
-# Pickle collected data for later comparisons.
-persistent = true
-
-# Use multiple processes to speed up Pylint.
-jobs = 0
-
-# List of plugins (as comma separated values of python modules names) to load,
-# usually to register additional checkers.
-# See https://chezsoi.org/lucas/blog/pylint-strict-base-configuration.html.
-# We do not use the plugins:
-# - pylint.extensions.code_style
-# - pylint.extensions.magic_value
-# - pylint.extensions.while_used
-# as they seemed to get in the way.
-load-plugins = [
- 'pylint.extensions.bad_builtin',
- 'pylint.extensions.comparison_placement',
- 'pylint.extensions.consider_refactoring_into_while_condition',
- 'pylint.extensions.docparams',
- 'pylint.extensions.dunder',
- 'pylint.extensions.eq_without_hash',
- 'pylint.extensions.for_any_all',
- 'pylint.extensions.mccabe',
- 'pylint.extensions.no_self_use',
- 'pylint.extensions.overlapping_exceptions',
- 'pylint.extensions.private_import',
- 'pylint.extensions.redefined_loop_name',
- 'pylint.extensions.redefined_variable_type',
- 'pylint.extensions.set_membership',
- 'pylint.extensions.typing',
-]
-
-# Allow loading of arbitrary C extensions. Extensions are imported into the
-# active Python interpreter and may run arbitrary code.
-unsafe-load-any-extension = false
-
-ignore = [
- '_setuptools_scm_version.py',
-]
-
-[tool.pylint.'MESSAGES CONTROL']
-
-# Enable the message, report, category or checker with the given id(s). You can
-# either give multiple identifier separated by comma (,) or put this option
-# multiple time (only on the command line, not in the configuration file where
-# it should appear only once). See also the "--disable" option for examples.
-enable = [
- 'bad-inline-option',
- 'deprecated-pragma',
- 'file-ignored',
- 'spelling',
- 'use-symbolic-message-instead',
- 'useless-suppression',
-]
-
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
@@ -218,162 +193,176 @@ enable = [
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use"--disable=all --enable=classes
# --disable=W"
-
-disable = [
- 'too-few-public-methods',
- 'too-many-locals',
- 'too-many-arguments',
- 'too-many-instance-attributes',
- 'too-many-return-statements',
- 'too-many-lines',
- 'locally-disabled',
+"MESSAGES CONTROL".disable = [
+ # Too difficult to please
+ "duplicate-code",
# Let ruff handle long lines
- 'line-too-long',
- # Let ruff handle unused imports
- 'unused-import',
- # Let ruff deal with sorting
- 'ungrouped-imports',
+ "line-too-long",
+ "locally-disabled",
+ "missing-return-type-doc",
# We don't need everything to be documented because of mypy
- 'missing-type-doc',
- 'missing-return-type-doc',
- # Too difficult to please
- 'duplicate-code',
+ "missing-type-doc",
+ "too-few-public-methods",
+ "too-many-arguments",
+ "too-many-instance-attributes",
+ "too-many-lines",
+ "too-many-locals",
+ "too-many-return-statements",
+ # Let ruff deal with sorting
+ "ungrouped-imports",
+ # Let ruff handle unused imports
+ "unused-import",
# Let ruff handle imports
- 'wrong-import-order',
+ "wrong-import-order",
+]
+# Enable the message, report, category or checker with the given id(s). You can
+# either give multiple identifier separated by comma (,) or put this option
+# multiple time (only on the command line, not in the configuration file where
+# it should appear only once). See also the "--disable" option for examples.
+"MESSAGES CONTROL".enable = [
+ "bad-inline-option",
+ "deprecated-pragma",
+ "file-ignored",
+ "spelling",
+ "use-symbolic-message-instead",
+ "useless-suppression",
+]
+DEPRECATED_BUILTINS.bad-functions = [
+ # Use Pylint until Ruff can ban bare builtin calls, or until custom rules
+ # make this removable:
+ # https://github.com/astral-sh/ruff/issues/10079
+ # https://github.com/astral-sh/ruff/issues/970
+ "filter",
+ "getattr",
+ "hasattr",
+ "map",
+ "setattr",
]
-
-[tool.pylint.'FORMAT']
-
# Allow the body of an if to be on the same line as the test if there is no
# else.
-single-line-if-stmt = false
-
-[tool.pylint.'SPELLING']
-
+FORMAT.single-line-if-stmt = false
+# Return non-zero exit code if useless-suppression is emitted.
+MAIN.fail-on = [
+ "useless-suppression",
+]
+MASTER.ignore = [
+ "_setuptools_scm_version.py",
+]
+# Use multiple processes to speed up Pylint.
+MASTER.jobs = 0
+# List of plugins (as comma separated values of python modules names) to load,
+# usually to register additional checkers.
+# See https://chezsoi.org/lucas/blog/pylint-strict-base-configuration.html.
+# We do not use the plugins:
+# - pylint.extensions.code_style
+# - pylint.extensions.magic_value
+# - pylint.extensions.while_used
+# as they seemed to get in the way.
+MASTER.load-plugins = [
+ "pylint.extensions.bad_builtin",
+ "pylint.extensions.comparison_placement",
+ "pylint.extensions.consider_refactoring_into_while_condition",
+ "pylint.extensions.docparams",
+ "pylint.extensions.dunder",
+ "pylint.extensions.eq_without_hash",
+ "pylint.extensions.for_any_all",
+ "pylint.extensions.mccabe",
+ "pylint.extensions.no_self_use",
+ "pylint.extensions.overlapping_exceptions",
+ "pylint.extensions.private_import",
+ "pylint.extensions.redefined_loop_name",
+ "pylint.extensions.redefined_variable_type",
+ "pylint.extensions.set_membership",
+ "pylint.extensions.typing",
+]
+# Pickle collected data for later comparisons.
+MASTER.persistent = true
+# Allow loading of arbitrary C extensions. Extensions are imported into the
+# active Python interpreter and may run arbitrary code.
+MASTER.unsafe-load-any-extension = false
# Spelling dictionary name. Available dictionaries: none. To make it working
# install python-enchant package.
-spelling-dict = 'en_US'
-
+SPELLING.spelling-dict = "en_US"
# A path to a file that contains private dictionary; one word per line.
-spelling-private-dict-file = 'spelling_private_dict.txt'
-
+SPELLING.spelling-private-dict-file = "spelling_private_dict.txt"
# Tells whether to store unknown words to indicated private dictionary in
# --spelling-private-dict-file option instead of raising a message.
-spelling-store-unknown-words = 'no'
+SPELLING.spelling-store-unknown-words = "no"
-[tool.docformatter]
-make-summary-multi-line = true
+[tool.interrogate]
+fail-under = 100
+exclude = [
+ "src/*/_setuptools_scm_version.py",
+]
+verbose = 2
+omit-covered-files = true
[tool.check-manifest]
-
ignore = [
- ".checkmake-config.ini",
- ".yamlfmt",
"*.enc",
+ ".checkmake-config.ini",
+ ".git_archival.txt",
".pre-commit-config.yaml",
+ ".prettierrc",
+ ".vale.ini",
+ ".yamlfmt",
+ "bin",
+ "bin/*",
"CHANGELOG.rst",
- "CODE_OF_CONDUCT.rst",
- "CONTRIBUTING.rst",
- "LICENSE",
- "Makefile",
"ci",
"ci/**",
- "codecov.yaml",
+ "CODE_OF_CONDUCT.rst",
+ "CONTRIBUTING.rst",
"docs",
"docs/**",
- ".git_archival.txt",
+ "flake.lock",
+ "flake.nix",
+ "LICENSE",
+ "lint.mk",
+ "Makefile",
+ "newsfragments",
+ "newsfragments/**",
"spelling_private_dict.txt",
+ "src/*/_setuptools_scm_version.py",
"tests",
"tests-pylintrc",
"tests/**",
"vuforia_secrets.env.example",
- "lint.mk",
- "bin",
- "bin/*",
- "src/*/_setuptools_scm_version.py",
+ "zizmor.yml",
]
[tool.deptry]
-pep621_dev_dependency_groups = [
+# torch and torchvision are listed explicitly in dev deps to allow
+# [tool.uv.sources] to redirect them to the CPU-only PyTorch index,
+# but they are not directly imported in the vws-cli source code.
+per_rule_ignores.DEP002 = [
+ "torch",
+ "torchvision",
+]
+optional_dependencies_dev_groups = [
"dev",
"packaging",
"release",
]
-[tool.pyproject-fmt]
-indent = 4
-keep_full_version = true
-max_supported_python = "3.13"
-
-[tool.pytest.ini_options]
-
-xfail_strict = true
-log_cli = true
-
-[tool.coverage.run]
-
-branch = true
-omit = [
- 'src/vws_cli/_setuptools_scm_version.py',
-]
-
-[tool.coverage.report]
-exclude_also = [
- "if TYPE_CHECKING:",
-]
-
-[tool.mypy]
-
-strict = true
-files = [ "." ]
-exclude = [ "build" ]
-follow_untyped_imports = true
-plugins = [
- "mypy_strict_kwargs",
-]
-
-[tool.pyright]
-
-enableTypeIgnoreComments = false
-reportUnnecessaryTypeIgnoreComment = true
-typeCheckingMode = "strict"
-
-[tool.interrogate]
-fail-under = 100
-omit-covered-files = true
-verbose = 2
+[tool.vulture]
exclude = [
- "src/*/_setuptools_scm_version.py",
-]
-
-[tool.doc8]
-
-max_line_length = 2000
-ignore_path = [
- "./.eggs",
- "./docs/build",
- "./docs/build/spelling/output.txt",
- "./node_modules",
- "./src/*.egg-info/",
- "./src/*/_setuptools_scm_version.txt",
+ # Duplicate some of .gitignore
+ ".venv",
+ # We ignore the version file as it is generated by setuptools_scm.
+ "_setuptools_scm_version.py",
]
-
-[tool.vulture]
# Ideally we would limit the paths to the source code where we want to ignore names,
# but Vulture does not enable this.
ignore_names = [
- # pytest configuration
- "pytest_collect_file",
- "pytest_collection_modifyitems",
- "pytest_plugins",
- # pytest fixtures - we name fixtures like this for this purpose
- "fixture_*",
# Sphinx
"autoclass_content",
"autoclass_content",
"autodoc_member_order",
"copybutton_exclude",
"extensions",
+ # pytest fixtures - we name fixtures like this for this purpose
+ "fixture_*",
"html_show_copyright",
"html_show_sourcelink",
"html_show_sphinx",
@@ -389,20 +378,101 @@ ignore_names = [
"nitpicky",
"project_copyright",
"pygments_style",
+ # pytest configuration
+ "pytest_collect_file",
+ "pytest_plugins",
"rst_prolog",
"source_suffix",
"spelling_word_list_filename",
"templates_path",
+ "towncrier_draft_autoversion_mode",
+ "towncrier_draft_include_empty",
+ "towncrier_draft_working_directory",
"warning_is_error",
]
-exclude = [
- # Duplicate some of .gitignore
- ".venv",
- # We ignore the version file as it is generated by setuptools_scm.
- "_setuptools_scm_version.py",
+[tool.pyproject-fmt]
+indent = 4
+keep_full_version = true
+max_supported_python = "3.14"
+
+[tool.mypy]
+files = [ "." ]
+exclude = [ "build" ]
+exclude_gitignore = true
+follow_untyped_imports = true
+strict = true
+plugins = [
+ "mypy_strict_kwargs",
+]
+
+[tool.pyrefly]
+errors.non-exhaustive-match = "error"
+
+[tool.pyright]
+typeCheckingMode = "strict"
+enableTypeIgnoreComments = false
+reportUnnecessaryTypeIgnoreComment = true
+
+[tool.pytest]
+log_cli = true
+xfail_strict = true
+
+[tool.coverage]
+run.branch = true
+run.omit = [
+ "src/vws_cli/_setuptools_scm_version.py",
+]
+report.exclude_also = [
+ "if TYPE_CHECKING:",
+]
+report.show_missing = true
+
+[tool.towncrier]
+# The changelog and the per-release GitHub release notes are both built
+# from news fragments under ``newsfragments/``. The release workflow
+# runs ``towncrier build`` to assemble them; contributors add one
+# fragment file per user-facing change.
+directory = "newsfragments"
+filename = "CHANGELOG.rst"
+# Custom template so an assembled version reproduces the historical
+# style exactly: a bare ```` heading (no project name, no
+# date) followed by a flat bullet list with no per-type sub-headings.
+template = "docs/towncrier_template.rst.jinja"
+title_format = "{version}"
+issue_format = "#{issue}"
+# ``title_format`` underline first, then any nested headings. A bare
+# version such as ``2026.05.18`` underlined with ``-`` matches every
+# pre-towncrier entry in CHANGELOG.rst.
+underlines = [ "-", "~", "^" ]
+type = [
+ # A single, unnamed fragment type keeps the assembled output as one
+ # flat bullet list, matching the historical changelog (which never
+ # grouped entries under "Features"/"Bugfixes"/... sub-headings).
+ { directory = "change", name = "", showcontent = true },
+]
+
+[tool.pydocstringformatter]
+write = true
+split-summary-body = false
+max-line-length = 75
+linewrap-full-docstring = true
+
+[tool.doc8]
+max_line_length = 2000
+ignore_path = [
+ "./.eggs",
+ "./docs/build",
+ "./docs/build/spelling/output.txt",
+ "./node_modules",
+ "./src/*.egg-info/",
+ "./src/*/_setuptools_scm_version.txt",
]
[tool.yamlfix]
section_whitelines = 1
whitelines = 1
+
+[tool.no_defaults]
+private_only = true
+per_file_enforcement."tests/**" = "all"
diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt
index 066a3619..c45f684b 100644
--- a/spelling_private_dict.txt
+++ b/spelling_private_dict.txt
@@ -1,5 +1,9 @@
+CSV
+OAuth
+Winget
admin
api
+ar
args
beartype
changelog
@@ -7,6 +11,8 @@ cli
dockerfile
dockerfiles
dulwich
+entrypoint
+executables
gzip
homebrew
linter
@@ -17,6 +23,8 @@ macOS
metadata
noqa
num
+pdf
+png
pragma
pre
pyperclip
@@ -28,9 +36,13 @@ reportMissingTypeStubs
reportUnknownArgumentType
reportUnknownMemberType
reportUnknownVariableType
+stdout
+svg
typeshed
ubuntu
versioned
vuforia
+vumark
vwq
vws
+winget
diff --git a/src/vws_cli/__init__.py b/src/vws_cli/__init__.py
index b62566c0..5077ff4d 100644
--- a/src/vws_cli/__init__.py
+++ b/src/vws_cli/__init__.py
@@ -1,6 +1,4 @@
-"""
-A CLI for Vuforia Web Services.
-"""
+"""A CLI for Vuforia Web Services."""
from importlib.metadata import PackageNotFoundError, version
@@ -10,6 +8,7 @@
from vws_cli.commands import (
add_target,
delete_target,
+ get_database_reco_counts_report,
get_database_summary_report,
get_duplicate_targets,
get_target_record,
@@ -18,6 +17,13 @@
update_target,
wait_for_target_processed,
)
+from vws_cli.model_target import (
+ create_model_target_dataset,
+ delete_model_target_dataset,
+ download_model_target_dataset,
+ get_model_target_dataset_status,
+ wait_for_model_target_dataset_generated,
+)
_CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
@@ -38,17 +44,21 @@
@click.version_option(version=__version__)
@beartype
def vws_group() -> None:
- """
- Manage a Vuforia Web Services cloud database.
- """
+ """Manage a Vuforia Web Services cloud database."""
vws_group.add_command(cmd=add_target)
+vws_group.add_command(cmd=create_model_target_dataset)
+vws_group.add_command(cmd=delete_model_target_dataset)
vws_group.add_command(cmd=delete_target)
+vws_group.add_command(cmd=download_model_target_dataset)
+vws_group.add_command(cmd=get_database_reco_counts_report)
vws_group.add_command(cmd=get_database_summary_report)
vws_group.add_command(cmd=get_duplicate_targets)
+vws_group.add_command(cmd=get_model_target_dataset_status)
vws_group.add_command(cmd=get_target_record)
vws_group.add_command(cmd=get_target_summary_report)
vws_group.add_command(cmd=list_targets)
vws_group.add_command(cmd=update_target)
+vws_group.add_command(cmd=wait_for_model_target_dataset_generated)
vws_group.add_command(cmd=wait_for_target_processed)
diff --git a/src/vws_cli/_error_handling.py b/src/vws_cli/_error_handling.py
new file mode 100644
index 00000000..63272a2b
--- /dev/null
+++ b/src/vws_cli/_error_handling.py
@@ -0,0 +1,116 @@
+"""Error handling utilities for the VWS CLI."""
+
+from beartype import beartype
+from vws.exceptions.custom_exceptions import (
+ RecoCountsReportDownloadError,
+ RecoCountsReportTimeoutError,
+ ServerError,
+ TargetProcessingTimeoutError,
+)
+from vws.exceptions.model_target_exceptions import (
+ ModelTargetAuthenticationError,
+ ModelTargetDatasetNotDoneError,
+ ModelTargetError,
+ ModelTargetOAuth2Error,
+ ModelTargetValidationError,
+ UnknownModelTargetDatasetError,
+)
+from vws.exceptions.vws_exceptions import (
+ AuthenticationFailureError,
+ BadImageError,
+ DateRangeError,
+ FailError,
+ ImageTooLargeError,
+ MetadataTooLargeError,
+ ProjectHasNoAPIAccessError,
+ ProjectInactiveError,
+ ProjectSuspendedError,
+ RequestQuotaReachedError,
+ RequestTimeTooSkewedError,
+ TargetNameExistError,
+ TargetQuotaReachedError,
+ TargetStatusNotSuccessError,
+ TargetStatusProcessingError,
+ UnknownTargetError,
+)
+
+
+@beartype
+def get_error_message(exc: Exception) -> str:
+ """Get an error message from a VWS exception."""
+ exc_type_to_message: dict[type[Exception], str] = {
+ AuthenticationFailureError: "The given secret key was incorrect.",
+ BadImageError: "Error: The given image is corrupted or the format is not supported.",
+ DateRangeError: "Error: There was a problem with the date details given in the request.",
+ FailError: "Error: The request made to Vuforia was invalid and could not be processed. Check the given parameters.",
+ ImageTooLargeError: "Error: The given image is too large.",
+ MetadataTooLargeError: "Error: The given metadata is too large.",
+ RecoCountsReportDownloadError: "Error: The recognition counts report could not be downloaded. This may be because the report's URL has expired.",
+ RecoCountsReportTimeoutError: "Error: The recognition counts report was not generated within the allowed limit.",
+ ServerError: "Error: There was an unknown error from Vuforia. This may be because there is a problem with the given name.",
+ ProjectInactiveError: "Error: The project associated with the given keys is inactive.",
+ RequestQuotaReachedError: "Error: The maximum number of API calls for this database has been reached.",
+ RequestTimeTooSkewedError: "Error: Vuforia reported that the time given with this request was outside the expected range. This may be because the system clock is out of sync.",
+ TargetProcessingTimeoutError: "Error: The target processing time has exceeded the allowed limit.",
+ TargetQuotaReachedError: "Error: The maximum number of targets for this database has been reached.",
+ ProjectSuspendedError: "Error: The request could not be completed because this database has been suspended.",
+ ProjectHasNoAPIAccessError: "Error: The request could not be completed because this database is not allowed to make API requests.",
+ }
+
+ match exc:
+ case UnknownTargetError():
+ return f'Error: Target "{exc.target_id}" does not exist.'
+ case TargetNameExistError():
+ return (
+ f'Error: There is already a target named "{exc.target_name}".'
+ )
+ case TargetStatusNotSuccessError():
+ return (
+ f'Error: The target "{exc.target_id}" cannot be updated as it is '
+ "in the processing state."
+ )
+ case TargetStatusProcessingError():
+ return (
+ f'Error: The target "{exc.target_id}" cannot be deleted as it is '
+ "in the processing state."
+ )
+ case _:
+ return exc_type_to_message[type(exc)]
+
+
+@beartype
+def get_model_target_error_message(
+ exc: ModelTargetError | ModelTargetOAuth2Error | ServerError,
+) -> str:
+ """Get an error message from a Model Target Web API exception."""
+ match exc:
+ case ModelTargetOAuth2Error():
+ message = (
+ "Error: The given client ID and client secret are not a set "
+ "of Model Target Web API credentials."
+ )
+ case ModelTargetAuthenticationError():
+ message = "Error: The request to Vuforia was not authenticated."
+ case UnknownModelTargetDatasetError():
+ message = (
+ "Error: No Model Target dataset of the given type matches "
+ "the given UUID."
+ )
+ case ModelTargetDatasetNotDoneError():
+ message = (
+ "Error: Vuforia has not finished generating the dataset, so "
+ "the dataset cannot be downloaded."
+ )
+ case ModelTargetValidationError():
+ problems = [
+ f"{detail.code}: {detail.message}" for detail in exc.details
+ ] or [exc.message]
+ message = "\n".join(
+ ["Error: Vuforia rejected the request.", *problems],
+ )
+ case ModelTargetError():
+ message = f"Error: {exc.message or 'Vuforia returned an error.'}"
+ case _:
+ message = get_error_message(exc=exc)
+
+ return message
diff --git a/src/vws_cli/commands.py b/src/vws_cli/commands.py
index 931a4f09..42180d5a 100644
--- a/src/vws_cli/commands.py
+++ b/src/vws_cli/commands.py
@@ -1,11 +1,14 @@
"""``click`` commands the VWS CLI."""
+import calendar
import contextlib
import dataclasses
+import datetime
import io
import sys
-from collections.abc import Callable, Iterator, Mapping
+from collections.abc import Generator
from pathlib import Path
+from zoneinfo import ZoneInfo
import click
import yaml
@@ -13,28 +16,14 @@
from vws import VWS
from vws.exceptions.base_exceptions import VWSError
from vws.exceptions.custom_exceptions import (
+ RecoCountsReportDownloadError,
+ RecoCountsReportTimeoutError,
ServerError,
TargetProcessingTimeoutError,
)
-from vws.exceptions.vws_exceptions import (
- AuthenticationFailureError,
- BadImageError,
- DateRangeError,
- FailError,
- ImageTooLargeError,
- MetadataTooLargeError,
- ProjectHasNoAPIAccessError,
- ProjectInactiveError,
- ProjectSuspendedError,
- RequestQuotaReachedError,
- RequestTimeTooSkewedError,
- TargetNameExistError,
- TargetQuotaReachedError,
- TargetStatusNotSuccessError,
- TargetStatusProcessingError,
- UnknownTargetError,
-)
+from vws.exceptions.vws_exceptions import AuthenticationFailureError
+from vws_cli._error_handling import get_error_message
from vws_cli.options.credentials import (
server_access_key_option,
server_secret_key_option,
@@ -48,52 +37,16 @@
target_name_option,
target_width_option,
)
-
-
-@beartype
-def _get_error_message(exc: Exception) -> str:
- """Get an error message from a VWS exception."""
- if isinstance(exc, UnknownTargetError):
- return f'Error: Target "{exc.target_id}" does not exist.'
-
- if isinstance(exc, TargetNameExistError):
- return f'Error: There is already a target named "{exc.target_name}".'
-
- if isinstance(exc, TargetStatusNotSuccessError):
- return (
- f'Error: The target "{exc.target_id}" cannot be updated as it is '
- "in the processing state."
- )
-
- if isinstance(exc, TargetStatusProcessingError):
- return (
- f'Error: The target "{exc.target_id}" cannot be deleted as it is '
- "in the processing state."
- )
-
- exc_type_to_message: Mapping[type[Exception], str] = {
- AuthenticationFailureError: "The given secret key was incorrect.",
- BadImageError: "Error: The given image is corrupted or the format is not supported.",
- DateRangeError: "Error: There was a problem with the date details given in the request.",
- FailError: "Error: The request made to Vuforia was invalid and could not be processed. Check the given parameters.",
- ImageTooLargeError: "Error: The given image is too large.",
- MetadataTooLargeError: "Error: The given metadata is too large.",
- ServerError: "Error: There was an unknown error from Vuforia. This may be because there is a problem with the given name.",
- ProjectInactiveError: "Error: The project associated with the given keys is inactive.",
- RequestQuotaReachedError: "Error: The maximum number of API calls for this database has been reached.",
- RequestTimeTooSkewedError: "Error: Vuforia reported that the time given with this request was outside the expected range. This may be because the system clock is out of sync.",
- TargetProcessingTimeoutError: "Error: The target processing time has exceeded the allowed limit.",
- TargetQuotaReachedError: "Error: The maximum number of targets for this database has been reached.",
- ProjectSuspendedError: "Error: The request could not be completed because this database has been suspended.",
- ProjectHasNoAPIAccessError: "Error: The request could not be completed because this database is not allowed to make API requests.",
- }
-
- return exc_type_to_message[type(exc)]
+from vws_cli.options.timeout import (
+ connection_timeout_seconds_option,
+ read_timeout_seconds_option,
+)
+from vws_cli.options.vws import base_vws_url_option, database_id_option
@beartype
@contextlib.contextmanager
-def handle_vws_exceptions() -> Iterator[None]:
+def _handle_vws_exceptions() -> Generator[None]:
"""Show error messages and catch exceptions from ``VWS-Python``."""
error_message = ""
@@ -101,10 +54,11 @@ def handle_vws_exceptions() -> Iterator[None]:
yield
except (
VWSError,
+ RecoCountsReportDownloadError,
ServerError,
TargetProcessingTimeoutError,
) as exc:
- error_message = _get_error_message(exc=exc)
+ error_message = get_error_message(exc=exc)
else:
return
@@ -112,35 +66,23 @@ def handle_vws_exceptions() -> Iterator[None]:
sys.exit(1)
-def base_vws_url_option(command: Callable[..., None]) -> Callable[..., None]:
- """An option decorator for choosing the base VWS URL."""
- click_option_function: Callable[
- [Callable[..., None]],
- Callable[..., None],
- ] = click.option(
- "--base-vws-url",
- type=click.STRING,
- default="https://vws.vuforia.com",
- help="The base URL for the VWS API.",
- show_default=True,
- )
-
- function: Callable[..., None] = click_option_function(command)
- return function
-
-
@click.command(name="get-target-record")
@server_access_key_option
@server_secret_key_option
@target_id_option
@base_vws_url_option
-@handle_vws_exceptions()
+@connection_timeout_seconds_option
+@read_timeout_seconds_option
+@_handle_vws_exceptions()
@beartype
def get_target_record(
+ *,
server_access_key: str,
server_secret_key: str,
target_id: str,
base_vws_url: str,
+ connection_timeout_seconds: float,
+ read_timeout_seconds: float,
) -> None:
"""Get a target record.
@@ -152,6 +94,10 @@ def get_target_record(
server_access_key=server_access_key,
server_secret_key=server_secret_key,
base_vws_url=base_vws_url,
+ request_timeout_seconds=(
+ connection_timeout_seconds,
+ read_timeout_seconds,
+ ),
)
record = vws_client.get_target_record(target_id=target_id).target_record
@@ -162,13 +108,18 @@ def get_target_record(
@click.command(name="list-targets")
@server_access_key_option
@server_secret_key_option
-@handle_vws_exceptions()
+@_handle_vws_exceptions()
@base_vws_url_option
+@connection_timeout_seconds_option
+@read_timeout_seconds_option
@beartype
def list_targets(
+ *,
server_access_key: str,
server_secret_key: str,
base_vws_url: str,
+ connection_timeout_seconds: float,
+ read_timeout_seconds: float,
) -> None:
"""List targets.
@@ -180,6 +131,10 @@ def list_targets(
server_access_key=server_access_key,
server_secret_key=server_secret_key,
base_vws_url=base_vws_url,
+ request_timeout_seconds=(
+ connection_timeout_seconds,
+ read_timeout_seconds,
+ ),
)
targets = vws_client.list_targets()
yaml_list = yaml.dump(data=targets)
@@ -190,14 +145,19 @@ def list_targets(
@server_access_key_option
@server_secret_key_option
@target_id_option
-@handle_vws_exceptions()
+@_handle_vws_exceptions()
@base_vws_url_option
+@connection_timeout_seconds_option
+@read_timeout_seconds_option
@beartype
def get_duplicate_targets(
+ *,
server_access_key: str,
server_secret_key: str,
target_id: str,
base_vws_url: str,
+ connection_timeout_seconds: float,
+ read_timeout_seconds: float,
) -> None:
"""Get a list of potential duplicate targets.
@@ -209,6 +169,10 @@ def get_duplicate_targets(
server_access_key=server_access_key,
server_secret_key=server_secret_key,
base_vws_url=base_vws_url,
+ request_timeout_seconds=(
+ connection_timeout_seconds,
+ read_timeout_seconds,
+ ),
)
record = vws_client.get_duplicate_targets(target_id=target_id)
@@ -219,13 +183,18 @@ def get_duplicate_targets(
@click.command(name="get-database-summary-report")
@server_access_key_option
@server_secret_key_option
-@handle_vws_exceptions()
+@_handle_vws_exceptions()
@base_vws_url_option
+@connection_timeout_seconds_option
+@read_timeout_seconds_option
@beartype
def get_database_summary_report(
+ *,
server_access_key: str,
server_secret_key: str,
base_vws_url: str,
+ connection_timeout_seconds: float,
+ read_timeout_seconds: float,
) -> None:
"""Get a database summary report.
@@ -237,6 +206,10 @@ def get_database_summary_report(
server_access_key=server_access_key,
server_secret_key=server_secret_key,
base_vws_url=base_vws_url,
+ request_timeout_seconds=(
+ connection_timeout_seconds,
+ read_timeout_seconds,
+ ),
)
report = vws_client.get_database_summary_report()
yaml_report = yaml.dump(data=dataclasses.asdict(obj=report))
@@ -247,14 +220,19 @@ def get_database_summary_report(
@server_access_key_option
@server_secret_key_option
@target_id_option
-@handle_vws_exceptions()
+@_handle_vws_exceptions()
@base_vws_url_option
+@connection_timeout_seconds_option
+@read_timeout_seconds_option
@beartype
def get_target_summary_report(
+ *,
server_access_key: str,
server_secret_key: str,
target_id: str,
base_vws_url: str,
+ connection_timeout_seconds: float,
+ read_timeout_seconds: float,
) -> None:
"""Get a target summary report.
@@ -266,6 +244,10 @@ def get_target_summary_report(
server_access_key=server_access_key,
server_secret_key=server_secret_key,
base_vws_url=base_vws_url,
+ request_timeout_seconds=(
+ connection_timeout_seconds,
+ read_timeout_seconds,
+ ),
)
report = vws_client.get_target_summary_report(target_id=target_id)
report_dict = dataclasses.asdict(obj=report)
@@ -279,14 +261,19 @@ def get_target_summary_report(
@server_access_key_option
@server_secret_key_option
@target_id_option
-@handle_vws_exceptions()
+@_handle_vws_exceptions()
@base_vws_url_option
+@connection_timeout_seconds_option
+@read_timeout_seconds_option
@beartype
def delete_target(
+ *,
server_access_key: str,
server_secret_key: str,
target_id: str,
base_vws_url: str,
+ connection_timeout_seconds: float,
+ read_timeout_seconds: float,
) -> None:
"""Delete a target.
@@ -298,6 +285,10 @@ def delete_target(
server_access_key=server_access_key,
server_secret_key=server_secret_key,
base_vws_url=base_vws_url,
+ request_timeout_seconds=(
+ connection_timeout_seconds,
+ read_timeout_seconds,
+ ),
)
vws_client.delete_target(target_id=target_id)
@@ -311,8 +302,10 @@ def delete_target(
@target_name_option(required=True)
@target_image_option(required=True)
@active_flag_option(allow_none=False)
-@handle_vws_exceptions()
+@_handle_vws_exceptions()
@base_vws_url_option
+@connection_timeout_seconds_option
+@read_timeout_seconds_option
@beartype
def add_target(
*,
@@ -323,6 +316,8 @@ def add_target(
image_file_path: Path,
active_flag_choice: ActiveFlagChoice,
base_vws_url: str,
+ connection_timeout_seconds: float,
+ read_timeout_seconds: float,
application_metadata: str | None = None,
) -> None:
"""Add a target.
@@ -335,6 +330,10 @@ def add_target(
server_access_key=server_access_key,
server_secret_key=server_secret_key,
base_vws_url=base_vws_url,
+ request_timeout_seconds=(
+ connection_timeout_seconds,
+ read_timeout_seconds,
+ ),
)
image_bytes = image_file_path.read_bytes()
@@ -365,8 +364,10 @@ def add_target(
@target_image_option(required=False)
@active_flag_option(allow_none=True)
@target_id_option
-@handle_vws_exceptions()
+@_handle_vws_exceptions()
@base_vws_url_option
+@connection_timeout_seconds_option
+@read_timeout_seconds_option
@beartype
def update_target(
*,
@@ -375,6 +376,8 @@ def update_target(
target_id: str,
image_file_path: Path | None,
base_vws_url: str,
+ connection_timeout_seconds: float,
+ read_timeout_seconds: float,
name: str | None = None,
application_metadata: str | None = None,
active_flag_choice: ActiveFlagChoice | None = None,
@@ -390,6 +393,10 @@ def update_target(
server_access_key=server_access_key,
server_secret_key=server_secret_key,
base_vws_url=base_vws_url,
+ request_timeout_seconds=(
+ connection_timeout_seconds,
+ read_timeout_seconds,
+ ),
)
if image_file_path is None:
@@ -448,7 +455,9 @@ def update_target(
@server_secret_key_option
@target_id_option
@base_vws_url_option
-@handle_vws_exceptions()
+@connection_timeout_seconds_option
+@read_timeout_seconds_option
+@_handle_vws_exceptions()
@beartype
def wait_for_target_processed(
*,
@@ -457,13 +466,21 @@ def wait_for_target_processed(
target_id: str,
seconds_between_requests: float,
base_vws_url: str,
+ connection_timeout_seconds: float,
+ read_timeout_seconds: float,
timeout_seconds: float,
) -> None:
- """Wait for a target to be "processed". This is done by polling the VWS API."""
+ """Wait for a target to be "processed". This is done by polling the VWS
+ API.
+ """
vws_client = VWS(
server_access_key=server_access_key,
server_secret_key=server_secret_key,
base_vws_url=base_vws_url,
+ request_timeout_seconds=(
+ connection_timeout_seconds,
+ read_timeout_seconds,
+ ),
)
try:
@@ -478,3 +495,183 @@ def wait_for_target_processed(
err=True,
)
sys.exit(1)
+
+
+_MONTH_FORMAT = "%Y-%m"
+
+_REPORT_SECONDS_BETWEEN_REQUESTS_HELP = (
+ "The number of seconds to wait between requests made while polling for "
+ "the report. "
+ f"We wait {_SECONDS_BETWEEN_REQUESTS_DEFAULT} seconds by default, rather "
+ "than less, than that to decrease the number of calls made to the API, to "
+ "decrease the likelihood of hitting the request quota."
+)
+
+_REPORT_TIMEOUT_SECONDS_HELP = (
+ "The maximum number of seconds to wait for the report to be generated."
+)
+
+
+@beartype
+def _default_month() -> str:
+ """Return the current month, in the form which ``--month`` takes."""
+ now = datetime.datetime.now(tz=ZoneInfo(key="UTC"))
+ return now.strftime(format=_MONTH_FORMAT)
+
+
+@beartype
+def _validate_month(
+ ctx: click.Context,
+ param: click.Parameter,
+ value: str,
+) -> datetime.date:
+ """Turn a ``YYYY-mm`` string into the first day of that month."""
+ # These are given by ``click``, and we do not use them.
+ del ctx
+ del param
+ try:
+ parsed = datetime.datetime.strptime( # noqa: DTZ007
+ value,
+ _MONTH_FORMAT,
+ )
+ except ValueError as exc:
+ message = f'"{value}" is not a month in the YYYY-mm form.'
+ raise click.BadParameter(message=message) from exc
+ return parsed.date()
+
+
+@click.command(name="get-database-reco-counts-report")
+@click.option(
+ "--month",
+ type=str,
+ default=_default_month,
+ callback=_validate_month,
+ help=(
+ "The month to get recognition counts for, in the YYYY-mm form. "
+ "Vuforia accepts only the current month and the previous month."
+ ),
+ show_default="the current month",
+)
+@click.option(
+ "--output",
+ "output_file_path",
+ type=click.Path(
+ dir_okay=False,
+ writable=True,
+ path_type=Path,
+ ),
+ required=False,
+ help=(
+ "The path to write the CSV report to. By default, the report is "
+ "written to stdout."
+ ),
+)
+@click.option(
+ "--no-wait",
+ "no_wait",
+ is_flag=True,
+ default=False,
+ help=(
+ "Do not wait for the report to be generated. Instead, show the URL "
+ "to download the report from once it has been generated."
+ ),
+)
+@click.option(
+ "--seconds-between-requests",
+ type=click.FloatRange(min=0.05),
+ default=_SECONDS_BETWEEN_REQUESTS_DEFAULT,
+ help=_REPORT_SECONDS_BETWEEN_REQUESTS_HELP,
+ show_default=True,
+)
+@click.option(
+ "--timeout-seconds",
+ type=click.FloatRange(min=0.05),
+ default=300,
+ help=_REPORT_TIMEOUT_SECONDS_HELP,
+ show_default=True,
+)
+@server_access_key_option
+@server_secret_key_option
+@database_id_option
+@base_vws_url_option
+@connection_timeout_seconds_option
+@read_timeout_seconds_option
+@_handle_vws_exceptions()
+@beartype
+def get_database_reco_counts_report(
+ *,
+ server_access_key: str,
+ server_secret_key: str,
+ database_id: str,
+ month: datetime.date,
+ output_file_path: Path | None,
+ no_wait: bool,
+ seconds_between_requests: float,
+ timeout_seconds: float,
+ base_vws_url: str,
+ connection_timeout_seconds: float,
+ read_timeout_seconds: float,
+) -> None:
+ """Get a per-target recognition counts report for a database.
+
+ The report is a CSV with a ``target_id,reco_count`` header. Vuforia
+ generates the report in the background, so by default we wait for the
+ report to be generated before downloading it.
+
+ \b
+ See
+ https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api.
+ """
+ if no_wait and output_file_path is not None:
+ message = "--output cannot be used with --no-wait."
+ raise click.UsageError(message=message)
+
+ vws_client = VWS(
+ server_access_key=server_access_key,
+ server_secret_key=server_secret_key,
+ base_vws_url=base_vws_url,
+ database_id=database_id,
+ request_timeout_seconds=(
+ connection_timeout_seconds,
+ read_timeout_seconds,
+ ),
+ )
+
+ try:
+ report_request = vws_client.request_database_reco_counts_report(
+ year=month.year,
+ month=calendar.Month(value=month.month),
+ )
+ except AuthenticationFailureError:
+ click.echo(
+ message=(
+ "Error: The given secret key was incorrect, or the given "
+ "database ID is not the ID of the database which the given "
+ "server keys belong to."
+ ),
+ err=True,
+ )
+ sys.exit(1)
+
+ if no_wait:
+ click.echo(message=report_request.presigned_url)
+ return
+
+ try:
+ report = vws_client.wait_for_reco_counts_report(
+ presigned_url=report_request.presigned_url,
+ seconds_between_requests=seconds_between_requests,
+ timeout_seconds=timeout_seconds,
+ )
+ except RecoCountsReportTimeoutError:
+ click.echo(
+ message=f"Timeout of {timeout_seconds} seconds reached.",
+ err=True,
+ )
+ sys.exit(1)
+
+ if output_file_path is None:
+ click.echo(message=report.raw_csv, nl=False)
+ return
+
+ output_file_path.write_bytes(data=report.raw_csv)
diff --git a/src/vws_cli/model_target.py b/src/vws_cli/model_target.py
new file mode 100644
index 00000000..39089f38
--- /dev/null
+++ b/src/vws_cli/model_target.py
@@ -0,0 +1,835 @@
+"""``click`` commands for the Vuforia Model Target Web API."""
+
+import base64
+import contextlib
+import dataclasses
+import json
+import sys
+from collections.abc import Generator, Sequence
+from enum import StrEnum
+from pathlib import Path
+from typing import Any
+
+import click
+import yaml
+from beartype import beartype
+from vws import ModelTargetService
+from vws.exceptions.custom_exceptions import ServerError
+from vws.exceptions.model_target_exceptions import (
+ ModelTargetDatasetTimeoutError,
+ ModelTargetError,
+ ModelTargetOAuth2Error,
+)
+from vws.model_target_datasets import (
+ AutomaticColoring,
+ CadDataFormat,
+ GuideViewPosition,
+ ModelTargetDatasetType,
+ ModelTargetModel,
+ ModelTargetView,
+ MotionHint,
+ OptimizeTrackingFor,
+ RealisticAppearance,
+ Simplify,
+ TrackingMode,
+)
+from vws.reports import (
+ ModelTargetDatasetStatuses,
+ ModelTargetDatasetStatusReport,
+)
+
+from vws_cli._error_handling import get_model_target_error_message
+from vws_cli.options.model_targets import (
+ client_id_option,
+ client_secret_option,
+ dataset_type_option,
+ dataset_uuid_option,
+)
+from vws_cli.options.timeout import (
+ connection_timeout_seconds_option,
+ read_timeout_seconds_option,
+)
+from vws_cli.options.vws import base_vws_url_option
+
+_SECONDS_BETWEEN_REQUESTS_DEFAULT = 0.2
+
+_SECONDS_BETWEEN_REQUESTS_HELP = (
+ "The number of seconds to wait between requests made while polling the "
+ "dataset status. "
+ f"We wait {_SECONDS_BETWEEN_REQUESTS_DEFAULT} seconds by default, rather "
+ "than less, than that to decrease the number of calls made to the API, to "
+ "decrease the likelihood of hitting the request quota."
+)
+
+_TIMEOUT_SECONDS_HELP = (
+ "The maximum number of seconds to wait for the dataset to be generated."
+)
+
+
+@beartype
+@contextlib.contextmanager
+def _handle_model_target_exceptions() -> Generator[None]:
+ """Show error messages and catch exceptions from ``VWS-Python``."""
+ error_message = ""
+
+ try:
+ yield
+ except (ModelTargetError, ModelTargetOAuth2Error, ServerError) as exc:
+ error_message = get_model_target_error_message(exc=exc)
+ else:
+ return
+
+ click.echo(message=error_message, err=True)
+ sys.exit(1)
+
+
+@beartype
+def _model_target_client(
+ *,
+ client_id: str,
+ client_secret: str,
+ base_vws_url: str,
+ connection_timeout_seconds: float,
+ read_timeout_seconds: float,
+) -> ModelTargetService:
+ """Get a client for the Model Target Web API."""
+ return ModelTargetService(
+ client_id=client_id,
+ client_secret=client_secret,
+ base_vws_url=base_vws_url,
+ request_timeout_seconds=(
+ connection_timeout_seconds,
+ read_timeout_seconds,
+ ),
+ )
+
+
+@beartype
+def _status_report_yaml(*, report: ModelTargetDatasetStatusReport) -> str:
+ """Get a YAML representation of a dataset status report."""
+ report_dict = dataclasses.asdict(obj=report)
+ report_dict["status"] = report.status.value
+ for key in ("created_at", "eta", "completed_at"):
+ value = report_dict[key]
+ report_dict[key] = None if value is None else str(object=value)
+ return yaml.dump(data=report_dict)
+
+
+_MODELS_FILE_HINT = "'--models-file'"
+
+_MODEL_STRING_FIELDS = {
+ "cadDataBlob": "cad_data_blob",
+ "cadDataUrl": "cad_data_url",
+ "name": "name",
+ "stateBasedConfigurationJsonString": (
+ "state_based_configuration_json_string"
+ ),
+}
+
+_MODEL_ENUM_FIELDS: dict[str, tuple[str, type[StrEnum]]] = {
+ "automaticColoring": ("automatic_coloring", AutomaticColoring),
+ "cadDataFormat": ("cad_data_format", CadDataFormat),
+ "motionHint": ("motion_hint", MotionHint),
+ "optimizeTrackingFor": ("optimize_tracking_for", OptimizeTrackingFor),
+ "realisticAppearance": ("realistic_appearance", RealisticAppearance),
+ "simplify": ("simplify", Simplify),
+ "trackingMode": ("tracking_mode", TrackingMode),
+}
+
+_MODEL_FIELDS = frozenset(
+ {*_MODEL_STRING_FIELDS, *_MODEL_ENUM_FIELDS, "views"},
+)
+
+_VIEW_FIELDS = frozenset({"guideViewPosition", "name", "states"})
+
+_POSITION_FIELDS = frozenset({"rotation", "translation"})
+
+
+@beartype
+def _models_file_error(*, message: str) -> click.BadParameter:
+ """Get an error to raise for an invalid models file."""
+ return click.BadParameter(message=message, param_hint=_MODELS_FILE_HINT)
+
+
+@beartype
+def _is_json_object(*, value: object) -> bool:
+ """Get whether a value from a models file is an object."""
+ return isinstance(value, dict)
+
+
+@beartype
+def _is_json_array(*, value: object) -> bool:
+ """Get whether a value from a models file is an array."""
+ return isinstance(value, list)
+
+
+@beartype
+def _as_json_object(*, value: object) -> dict[str, Any] | None:
+ """Get an object from a models file, or ``None``."""
+ if not _is_json_object(value=value):
+ return None
+ # The value goes through a variable which is typed as ``Any`` so that
+ # the keys and values of the returned object are not unknown types.
+ value_any: Any = value
+ value_dict: dict[str, Any] = value_any
+ return value_dict
+
+
+@beartype
+def _json_object(*, value: object, message: str) -> dict[str, Any]:
+ """Get an object from a models file, or raise an error."""
+ value_dict = _as_json_object(value=value)
+ if value_dict is None:
+ raise _models_file_error(message=message)
+ return value_dict
+
+
+@beartype
+def _json_array(*, value: object, message: str) -> list[Any]:
+ """Get an array from a models file, or raise an error."""
+ if not _is_json_array(value=value):
+ raise _models_file_error(message=message)
+ # The value goes through a variable which is typed as ``Any`` so that
+ # the items of the returned array are not unknown types.
+ value_any: Any = value
+ value_list: list[Any] = value_any
+ return value_list
+
+
+@beartype
+def _checked_object(
+ *,
+ value: object,
+ known_fields: frozenset[str],
+ required_fields: Sequence[str],
+ path: str,
+) -> dict[str, Any]:
+ """Get an object with known and required fields, or raise an error."""
+ value_dict = _json_object(
+ value=value,
+ message=f"{path} must be an object.",
+ )
+ unknown_fields = sorted(set(value_dict) - known_fields)
+ if unknown_fields:
+ message = f"{path} has unknown fields: {', '.join(unknown_fields)}."
+ raise _models_file_error(message=message)
+
+ for required_field in required_fields:
+ if required_field not in value_dict:
+ message = f"{path}/{required_field} is required."
+ raise _models_file_error(message=message)
+
+ return value_dict
+
+
+@beartype
+def _string_value(*, value: object, path: str) -> str:
+ """Get a string from a models file, or raise an error."""
+ if not isinstance(value, str):
+ message = f"{path} must be a string."
+ raise _models_file_error(message=message)
+ return value
+
+
+@beartype
+def _enum_value(
+ *,
+ value: object,
+ enum_type: type[StrEnum],
+ path: str,
+) -> StrEnum:
+ """Get an enumeration member from a models file, or raise an error."""
+ string_value = _string_value(value=value, path=path)
+ try:
+ return enum_type(value=string_value)
+ except ValueError as exc:
+ allowed = ", ".join(sorted(member.value for member in enum_type))
+ message = f"{path} must be one of: {allowed}."
+ raise _models_file_error(message=message) from exc
+
+
+@beartype
+def _number_sequence(*, value: object, path: str) -> Sequence[float]:
+ """Get a sequence of numbers from a models file, or raise an error."""
+ message = f"{path} must be an array of numbers."
+ items = _json_array(value=value, message=message)
+ for item in items:
+ if isinstance(item, bool) or not isinstance(item, int | float):
+ raise _models_file_error(message=message)
+
+ return [float(item) for item in items]
+
+
+@beartype
+def _guide_view_position_from_json(
+ *,
+ value: object,
+ path: str,
+) -> GuideViewPosition:
+ """Get a guide view position from a models file, or raise an error."""
+ position_dict = _checked_object(
+ value=value,
+ known_fields=_POSITION_FIELDS,
+ required_fields=("rotation", "translation"),
+ path=path,
+ )
+ return GuideViewPosition(
+ rotation=_number_sequence(
+ value=position_dict["rotation"],
+ path=f"{path}/rotation",
+ ),
+ translation=_number_sequence(
+ value=position_dict["translation"],
+ path=f"{path}/translation",
+ ),
+ )
+
+
+@beartype
+def _view_from_json(*, value: object, path: str) -> ModelTargetView:
+ """Get a guide view from a models file, or raise an error."""
+ view_dict = _checked_object(
+ value=value,
+ known_fields=_VIEW_FIELDS,
+ required_fields=("guideViewPosition", "name"),
+ path=path,
+ )
+
+ states: Sequence[str] | None = None
+ if "states" in view_dict:
+ states_items = _json_array(
+ value=view_dict["states"],
+ message=f"{path}/states must be an array of strings.",
+ )
+ states = [
+ _string_value(value=state, path=f"{path}/states({index})")
+ for index, state in enumerate(iterable=states_items)
+ ]
+
+ return ModelTargetView(
+ name=_string_value(value=view_dict["name"], path=f"{path}/name"),
+ guide_view_position=_guide_view_position_from_json(
+ value=view_dict["guideViewPosition"],
+ path=f"{path}/guideViewPosition",
+ ),
+ states=states,
+ )
+
+
+@beartype
+def _model_from_json(*, value: object, path: str) -> ModelTargetModel:
+ """Get a model from a models file, or raise an error."""
+ model_dict = _checked_object(
+ value=value,
+ known_fields=_MODEL_FIELDS,
+ required_fields=("name",),
+ path=path,
+ )
+
+ model_kwargs: dict[str, Any] = {
+ field_name: _string_value(
+ value=model_dict[json_field],
+ path=f"{path}/{json_field}",
+ )
+ for json_field, field_name in _MODEL_STRING_FIELDS.items()
+ if json_field in model_dict
+ }
+
+ for json_field, (field_name, enum_type) in _MODEL_ENUM_FIELDS.items():
+ if json_field in model_dict:
+ model_kwargs[field_name] = _enum_value(
+ value=model_dict[json_field],
+ enum_type=enum_type,
+ path=f"{path}/{json_field}",
+ )
+
+ if "views" in model_dict:
+ views_items = _json_array(
+ value=model_dict["views"],
+ message=f"{path}/views must be an array.",
+ )
+ model_kwargs["views"] = [
+ _view_from_json(value=view_json, path=f"{path}/views({index})")
+ for index, view_json in enumerate(iterable=views_items)
+ ]
+
+ return ModelTargetModel(**model_kwargs)
+
+
+@beartype
+def _models_from_file(
+ *,
+ models_file_path: Path,
+) -> Sequence[ModelTargetModel]:
+ """Get the models described by a models file, or raise an error."""
+ try:
+ file_json: object = json.loads(s=models_file_path.read_text())
+ except json.JSONDecodeError as exc:
+ message = f"{models_file_path} is not valid JSON."
+ raise _models_file_error(message=message) from exc
+
+ models_json: object = file_json
+ file_dict = _as_json_object(value=file_json)
+ if file_dict is not None:
+ if "models" not in file_dict:
+ message = "/models is required."
+ raise _models_file_error(message=message)
+ models_json = file_dict["models"]
+
+ models_items = _json_array(
+ value=models_json,
+ message="/models must be an array.",
+ )
+ return [
+ _model_from_json(value=model_json, path=f"/models({index})")
+ for index, model_json in enumerate(iterable=models_items)
+ ]
+
+
+@click.command(name="create-model-target-dataset")
+@click.option(
+ "--name",
+ type=str,
+ required=True,
+ help="The name of the Model Target dataset.",
+)
+@click.option(
+ "--target-sdk",
+ type=str,
+ required=True,
+ help=(
+ "The Vuforia Engine version to generate the dataset for, for example "
+ '"10.29".'
+ ),
+)
+@dataset_type_option
+@click.option(
+ "--model-name",
+ type=str,
+ help=(
+ "The name of the model to generate the dataset from. This is "
+ "required unless --models-file is given."
+ ),
+)
+@click.option(
+ "--cad-data-url",
+ type=str,
+ help="A URL which Vuforia downloads the model's CAD data from.",
+)
+@click.option(
+ "--cad-data-file",
+ "cad_data_file_path",
+ type=click.Path(
+ exists=True,
+ dir_okay=False,
+ path_type=Path,
+ ),
+ help=(
+ "The path to a file with the model's CAD data. The file is sent to "
+ "Vuforia as base64 encoded data."
+ ),
+)
+@click.option(
+ "--automatic-coloring",
+ type=click.Choice(choices=AutomaticColoring, case_sensitive=False),
+ help="Whether Vuforia colors the model automatically.",
+)
+@click.option(
+ "--cad-data-format",
+ type=click.Choice(choices=CadDataFormat, case_sensitive=False),
+ help="The format of the model's CAD data.",
+)
+@click.option(
+ "--motion-hint",
+ type=click.Choice(choices=MotionHint, case_sensitive=False),
+ help="How the object which the model represents moves.",
+)
+@click.option(
+ "--optimize-tracking-for",
+ type=click.Choice(choices=OptimizeTrackingFor, case_sensitive=False),
+ help="What Vuforia optimizes tracking of the model for.",
+)
+@click.option(
+ "--realistic-appearance",
+ type=click.Choice(choices=RealisticAppearance, case_sensitive=False),
+ help=(
+ "Whether the model looks like the object it represents. This is "
+ "documented for advanced datasets only."
+ ),
+)
+@click.option(
+ "--simplify",
+ type=click.Choice(choices=Simplify, case_sensitive=False),
+ help="Whether Vuforia simplifies the model.",
+)
+@click.option(
+ "--tracking-mode",
+ type=click.Choice(choices=TrackingMode, case_sensitive=False),
+ help="The tracking mode to generate the dataset for.",
+)
+@click.option(
+ "--state-based-configuration-file",
+ "state_based_configuration_file_path",
+ type=click.Path(
+ exists=True,
+ dir_okay=False,
+ path_type=Path,
+ ),
+ help=(
+ "The path to a JSON file with the model's State-Based Model Target "
+ "configuration."
+ ),
+)
+@click.option(
+ "--models-file",
+ "models_file_path",
+ type=click.Path(
+ exists=True,
+ dir_okay=False,
+ path_type=Path,
+ ),
+ help=(
+ "The path to a JSON file which describes the models to generate the "
+ "dataset from, in the form which the Model Target Web API takes. The "
+ "file holds either an array of models or an object with a "
+ '"models" array. This is the only way to give multiple models, or '
+ "to give guide views, and it cannot be used with the other model "
+ "options."
+ ),
+)
+@client_id_option
+@client_secret_option
+@base_vws_url_option
+@connection_timeout_seconds_option
+@read_timeout_seconds_option
+@_handle_model_target_exceptions()
+@beartype
+def create_model_target_dataset(
+ *,
+ client_id: str,
+ client_secret: str,
+ name: str,
+ target_sdk: str,
+ dataset_type: ModelTargetDatasetType,
+ model_name: str | None,
+ cad_data_url: str | None,
+ cad_data_file_path: Path | None,
+ automatic_coloring: AutomaticColoring | None,
+ cad_data_format: CadDataFormat | None,
+ motion_hint: MotionHint | None,
+ optimize_tracking_for: OptimizeTrackingFor | None,
+ realistic_appearance: RealisticAppearance | None,
+ simplify: Simplify | None,
+ tracking_mode: TrackingMode | None,
+ state_based_configuration_file_path: Path | None,
+ models_file_path: Path | None,
+ base_vws_url: str,
+ connection_timeout_seconds: float,
+ read_timeout_seconds: float,
+) -> None:
+ """Create a Model Target dataset.
+
+ Vuforia generates the dataset in the background, so the dataset is not
+ available to download immediately. The UUID of the new dataset is shown.
+
+ \b
+ See
+ https://developer.vuforia.com/library/vuforia-engine/web-api/model-target-web-api/
+ """
+ model_option_values: dict[str, object] = {
+ "--automatic-coloring": automatic_coloring,
+ "--cad-data-file": cad_data_file_path,
+ "--cad-data-format": cad_data_format,
+ "--cad-data-url": cad_data_url,
+ "--model-name": model_name,
+ "--motion-hint": motion_hint,
+ "--optimize-tracking-for": optimize_tracking_for,
+ "--realistic-appearance": realistic_appearance,
+ "--simplify": simplify,
+ "--state-based-configuration-file": (
+ state_based_configuration_file_path
+ ),
+ "--tracking-mode": tracking_mode,
+ }
+
+ if models_file_path is not None:
+ given_model_options = sorted(
+ option_name
+ for option_name, value in model_option_values.items()
+ if value is not None
+ )
+ if given_model_options:
+ message = (
+ "--models-file cannot be used with "
+ f"{', '.join(given_model_options)}."
+ )
+ raise click.UsageError(message=message)
+ models = _models_from_file(models_file_path=models_file_path)
+ else:
+ if model_name is None:
+ message = "--model-name is required when --models-file is not."
+ raise click.UsageError(message=message)
+
+ if (cad_data_url is None) == (cad_data_file_path is None):
+ message = (
+ "Exactly one of --cad-data-url and --cad-data-file is "
+ "required."
+ )
+ raise click.UsageError(message=message)
+
+ cad_data_blob: str | None = None
+ if cad_data_file_path is not None:
+ cad_data_blob = base64.b64encode(
+ s=cad_data_file_path.read_bytes(),
+ ).decode(encoding="ascii")
+
+ state_based_configuration_json_string: str | None = None
+ if state_based_configuration_file_path is not None:
+ state_based_configuration_json_string = (
+ state_based_configuration_file_path.read_text()
+ )
+
+ models = [
+ ModelTargetModel(
+ name=model_name,
+ cad_data_url=cad_data_url,
+ cad_data_blob=cad_data_blob,
+ automatic_coloring=automatic_coloring,
+ cad_data_format=cad_data_format,
+ motion_hint=motion_hint,
+ optimize_tracking_for=optimize_tracking_for,
+ realistic_appearance=realistic_appearance,
+ simplify=simplify,
+ tracking_mode=tracking_mode,
+ state_based_configuration_json_string=(
+ state_based_configuration_json_string
+ ),
+ ),
+ ]
+
+ model_target_client = _model_target_client(
+ client_id=client_id,
+ client_secret=client_secret,
+ base_vws_url=base_vws_url,
+ connection_timeout_seconds=connection_timeout_seconds,
+ read_timeout_seconds=read_timeout_seconds,
+ )
+
+ dataset_uuid = model_target_client.create_dataset(
+ name=name,
+ target_sdk=target_sdk,
+ models=models,
+ dataset_type=dataset_type,
+ )
+
+ click.echo(message=dataset_uuid)
+
+
+@click.command(name="get-model-target-dataset-status")
+@dataset_uuid_option
+@dataset_type_option
+@client_id_option
+@client_secret_option
+@base_vws_url_option
+@connection_timeout_seconds_option
+@read_timeout_seconds_option
+@_handle_model_target_exceptions()
+@beartype
+def get_model_target_dataset_status(
+ *,
+ client_id: str,
+ client_secret: str,
+ dataset_uuid: str,
+ dataset_type: ModelTargetDatasetType,
+ base_vws_url: str,
+ connection_timeout_seconds: float,
+ read_timeout_seconds: float,
+) -> None:
+ """Get the status of a Model Target dataset.
+
+ \b
+ See
+ https://developer.vuforia.com/library/vuforia-engine/web-api/model-target-web-api/
+ """
+ model_target_client = _model_target_client(
+ client_id=client_id,
+ client_secret=client_secret,
+ base_vws_url=base_vws_url,
+ connection_timeout_seconds=connection_timeout_seconds,
+ read_timeout_seconds=read_timeout_seconds,
+ )
+
+ report = model_target_client.get_dataset_status(
+ dataset_uuid=dataset_uuid,
+ dataset_type=dataset_type,
+ )
+
+ click.echo(message=_status_report_yaml(report=report))
+
+
+@click.command(name="wait-for-model-target-dataset-generated")
+@click.option(
+ "--seconds-between-requests",
+ type=click.FloatRange(min=0.05),
+ default=_SECONDS_BETWEEN_REQUESTS_DEFAULT,
+ help=_SECONDS_BETWEEN_REQUESTS_HELP,
+ show_default=True,
+)
+@click.option(
+ "--timeout-seconds",
+ type=click.FloatRange(min=0.05),
+ default=300,
+ help=_TIMEOUT_SECONDS_HELP,
+ show_default=True,
+)
+@dataset_uuid_option
+@dataset_type_option
+@client_id_option
+@client_secret_option
+@base_vws_url_option
+@connection_timeout_seconds_option
+@read_timeout_seconds_option
+@_handle_model_target_exceptions()
+@beartype
+def wait_for_model_target_dataset_generated(
+ *,
+ client_id: str,
+ client_secret: str,
+ dataset_uuid: str,
+ dataset_type: ModelTargetDatasetType,
+ seconds_between_requests: float,
+ timeout_seconds: float,
+ base_vws_url: str,
+ connection_timeout_seconds: float,
+ read_timeout_seconds: float,
+) -> None:
+ """Wait for Vuforia to finish generating a Model Target dataset.
+
+ This is done by polling the Model Target Web API. The status of the
+ dataset is shown once Vuforia has finished with it. A dataset which
+ failed to generate is also finished, and gives a non-zero exit code.
+ """
+ model_target_client = _model_target_client(
+ client_id=client_id,
+ client_secret=client_secret,
+ base_vws_url=base_vws_url,
+ connection_timeout_seconds=connection_timeout_seconds,
+ read_timeout_seconds=read_timeout_seconds,
+ )
+
+ try:
+ report = model_target_client.wait_for_dataset_generated(
+ dataset_uuid=dataset_uuid,
+ dataset_type=dataset_type,
+ seconds_between_requests=seconds_between_requests,
+ timeout_seconds=timeout_seconds,
+ )
+ except ModelTargetDatasetTimeoutError:
+ click.echo(
+ message=f"Timeout of {timeout_seconds} seconds reached.",
+ err=True,
+ )
+ sys.exit(1)
+
+ click.echo(message=_status_report_yaml(report=report))
+
+ if report.status == ModelTargetDatasetStatuses.FAILED:
+ click.echo(
+ message="Error: Vuforia failed to generate the dataset.",
+ err=True,
+ )
+ sys.exit(1)
+
+
+@click.command(name="download-model-target-dataset")
+@click.option(
+ "--output",
+ "output_file_path",
+ type=click.Path(
+ dir_okay=False,
+ writable=True,
+ path_type=Path,
+ ),
+ required=True,
+ help="The path to write the generated dataset zip file to.",
+)
+@dataset_uuid_option
+@dataset_type_option
+@client_id_option
+@client_secret_option
+@base_vws_url_option
+@connection_timeout_seconds_option
+@read_timeout_seconds_option
+@_handle_model_target_exceptions()
+@beartype
+def download_model_target_dataset(
+ *,
+ client_id: str,
+ client_secret: str,
+ dataset_uuid: str,
+ dataset_type: ModelTargetDatasetType,
+ output_file_path: Path,
+ base_vws_url: str,
+ connection_timeout_seconds: float,
+ read_timeout_seconds: float,
+) -> None:
+ """Download a generated Model Target dataset.
+
+ \b
+ See
+ https://developer.vuforia.com/library/vuforia-engine/web-api/model-target-web-api/
+ """
+ model_target_client = _model_target_client(
+ client_id=client_id,
+ client_secret=client_secret,
+ base_vws_url=base_vws_url,
+ connection_timeout_seconds=connection_timeout_seconds,
+ read_timeout_seconds=read_timeout_seconds,
+ )
+
+ dataset = model_target_client.download_dataset(
+ dataset_uuid=dataset_uuid,
+ dataset_type=dataset_type,
+ )
+
+ output_file_path.write_bytes(data=dataset)
+
+
+@click.command(name="delete-model-target-dataset")
+@dataset_uuid_option
+@dataset_type_option
+@client_id_option
+@client_secret_option
+@base_vws_url_option
+@connection_timeout_seconds_option
+@read_timeout_seconds_option
+@_handle_model_target_exceptions()
+@beartype
+def delete_model_target_dataset(
+ *,
+ client_id: str,
+ client_secret: str,
+ dataset_uuid: str,
+ dataset_type: ModelTargetDatasetType,
+ base_vws_url: str,
+ connection_timeout_seconds: float,
+ read_timeout_seconds: float,
+) -> None:
+ """Delete a Model Target dataset.
+
+ \b
+ See
+ https://developer.vuforia.com/library/vuforia-engine/web-api/model-target-web-api/
+ """
+ model_target_client = _model_target_client(
+ client_id=client_id,
+ client_secret=client_secret,
+ base_vws_url=base_vws_url,
+ connection_timeout_seconds=connection_timeout_seconds,
+ read_timeout_seconds=read_timeout_seconds,
+ )
+
+ model_target_client.delete_dataset(
+ dataset_uuid=dataset_uuid,
+ dataset_type=dataset_type,
+ )
diff --git a/src/vws_cli/options/__init__.py b/src/vws_cli/options/__init__.py
index e001e2d2..834d6fb5 100644
--- a/src/vws_cli/options/__init__.py
+++ b/src/vws_cli/options/__init__.py
@@ -1,3 +1 @@
-"""
-Options for the VWS CLI commands.
-"""
+"""Options for the VWS CLI commands."""
diff --git a/src/vws_cli/options/credentials.py b/src/vws_cli/options/credentials.py
index 246e87a6..353c7c8f 100644
--- a/src/vws_cli/options/credentials.py
+++ b/src/vws_cli/options/credentials.py
@@ -1,22 +1,18 @@
-"""
-``click`` options regarding credentials.
-"""
+"""``click`` options regarding credentials."""
from collections.abc import Callable
+from typing import Any
import click
+from beartype import beartype
+@beartype
def server_access_key_option(
- command: Callable[..., None] | None = None,
-) -> Callable[..., None]:
- """
- An option decorator for the Vuforia server access key.
- """
- click_option_function: Callable[
- [Callable[..., None]],
- Callable[..., None],
- ] = click.option(
+ command: Callable[..., Any],
+) -> Callable[..., Any]:
+ """An option decorator for the Vuforia server access key."""
+ return click.option(
"--server-access-key",
type=str,
help=(
@@ -26,22 +22,15 @@ def server_access_key_option(
required=True,
envvar="VUFORIA_SERVER_ACCESS_KEY",
show_envvar=True,
- )
- assert command is not None
- function: Callable[..., None] = click_option_function(command)
- return function
+ )(command)
+@beartype
def server_secret_key_option(
- command: Callable[..., None] | None = None,
-) -> Callable[..., None]:
- """
- An option decorator for the Vuforia server secret key.
- """
- click_option_function: Callable[
- [Callable[..., None]],
- Callable[..., None],
- ] = click.option(
+ command: Callable[..., Any],
+) -> Callable[..., Any]:
+ """An option decorator for the Vuforia server secret key."""
+ return click.option(
"--server-secret-key",
type=str,
help=(
@@ -51,22 +40,15 @@ def server_secret_key_option(
required=True,
envvar="VUFORIA_SERVER_SECRET_KEY",
show_envvar=True,
- )
- assert command is not None
- function: Callable[..., None] = click_option_function(command)
- return function
+ )(command)
+@beartype
def client_access_key_option(
- command: Callable[..., None] | None = None,
-) -> Callable[..., None]:
- """
- An option decorator for the Vuforia client access key.
- """
- click_option_function: Callable[
- [Callable[..., None]],
- Callable[..., None],
- ] = click.option(
+ command: Callable[..., Any],
+) -> Callable[..., Any]:
+ """An option decorator for the Vuforia client access key."""
+ return click.option(
"--client-access-key",
type=str,
help=(
@@ -76,22 +58,15 @@ def client_access_key_option(
required=True,
envvar="VUFORIA_CLIENT_ACCESS_KEY",
show_envvar=True,
- )
- assert command is not None
- function: Callable[..., None] = click_option_function(command)
- return function
+ )(command)
+@beartype
def client_secret_key_option(
- command: Callable[..., None] | None = None,
-) -> Callable[..., None]:
- """
- An option decorator for the Vuforia client secret key.
- """
- click_option_function: Callable[
- [Callable[..., None]],
- Callable[..., None],
- ] = click.option(
+ command: Callable[..., Any],
+) -> Callable[..., Any]:
+ """An option decorator for the Vuforia client secret key."""
+ return click.option(
"--client-secret-key",
type=str,
help=(
@@ -101,7 +76,4 @@ def client_secret_key_option(
required=True,
envvar="VUFORIA_CLIENT_SECRET_KEY",
show_envvar=True,
- )
- assert command is not None
- function: Callable[..., None] = click_option_function(command)
- return function
+ )(command)
diff --git a/src/vws_cli/options/model_targets.py b/src/vws_cli/options/model_targets.py
new file mode 100644
index 00000000..b04dc2ef
--- /dev/null
+++ b/src/vws_cli/options/model_targets.py
@@ -0,0 +1,81 @@
+"""``click`` options regarding Model Target datasets."""
+
+from collections.abc import Callable
+from typing import Any
+
+import click
+from beartype import beartype
+from vws.model_target_datasets import ModelTargetDatasetType
+
+
+@beartype
+def client_id_option(
+ command: Callable[..., Any],
+) -> Callable[..., Any]:
+ """An option decorator for the Model Target Web API client ID."""
+ return click.option(
+ "--client-id",
+ type=str,
+ help=(
+ "A Vuforia OAuth2 client ID to use to access the Model Target "
+ "Web API."
+ ),
+ required=True,
+ envvar="VUFORIA_MODEL_TARGET_CLIENT_ID",
+ show_envvar=True,
+ )(command)
+
+
+@beartype
+def client_secret_option(
+ command: Callable[..., Any],
+) -> Callable[..., Any]:
+ """An option decorator for the Model Target Web API client secret."""
+ return click.option(
+ "--client-secret",
+ type=str,
+ help=(
+ "A Vuforia OAuth2 client secret to use to access the Model "
+ "Target Web API."
+ ),
+ required=True,
+ envvar="VUFORIA_MODEL_TARGET_CLIENT_SECRET",
+ show_envvar=True,
+ )(command)
+
+
+@beartype
+def dataset_type_option(
+ command: Callable[..., Any],
+) -> Callable[..., Any]:
+ """An option decorator for the kind of Model Target dataset."""
+ return click.option(
+ "--dataset-type",
+ type=click.Choice(
+ choices=ModelTargetDatasetType,
+ case_sensitive=False,
+ ),
+ default=ModelTargetDatasetType.STANDARD.value,
+ help=(
+ "The kind of Model Target dataset. Standard and advanced "
+ "datasets are separate resources, so a dataset created as one "
+ "type is not visible to requests for the other type."
+ ),
+ show_default=True,
+ )(command)
+
+
+@beartype
+def dataset_uuid_option(
+ command: Callable[..., Any],
+) -> Callable[..., Any]:
+ """An option decorator for the UUID of a Model Target dataset."""
+ return click.option(
+ "--dataset-uuid",
+ type=str,
+ help=(
+ "The UUID of a Model Target dataset, as given when the dataset "
+ "was created."
+ ),
+ required=True,
+ )(command)
diff --git a/src/vws_cli/options/targets.py b/src/vws_cli/options/targets.py
index 91aa2f12..884dee67 100644
--- a/src/vws_cli/options/targets.py
+++ b/src/vws_cli/options/targets.py
@@ -1,6 +1,4 @@
-"""
-``click`` options regarding targets.
-"""
+"""``click`` options regarding targets."""
from collections.abc import Callable
from enum import Enum, unique
@@ -10,7 +8,7 @@
import click
from beartype import beartype
-target_id_option: Callable[..., None] = click.option(
+target_id_option: Callable[..., Any] = click.option(
"--target-id",
type=str,
help="The ID of a target in the Vuforia database.",
@@ -19,10 +17,8 @@
@beartype
-def target_name_option(*, required: bool) -> Callable[..., None]:
- """
- An option decorator for choosing a target name.
- """
+def target_name_option(*, required: bool) -> Callable[..., Any]:
+ """An option decorator for choosing a target name."""
return click.option(
"--name",
type=str,
@@ -33,9 +29,7 @@ def target_name_option(*, required: bool) -> Callable[..., None]:
@beartype
def target_width_option(*, required: bool) -> Callable[..., Any]:
- """
- An option decorator for choosing a target width.
- """
+ """An option decorator for choosing a target width."""
option: Callable[..., Any] = click.option(
"--width",
type=float,
@@ -47,9 +41,7 @@ def target_width_option(*, required: bool) -> Callable[..., Any]:
@beartype
def target_image_option(*, required: bool) -> Callable[..., Any]:
- """
- An option decorator for choosing a target image.
- """
+ """An option decorator for choosing a target image."""
return click.option(
"--image",
"image_file_path",
@@ -66,39 +58,18 @@ def target_image_option(*, required: bool) -> Callable[..., Any]:
@unique
class ActiveFlagChoice(Enum):
- """
- Choices for active flag.
- """
+ """Choices for active flag."""
TRUE = "true"
FALSE = "false"
-def _active_flag_choice_callback(
- ctx: click.core.Context,
- param: click.core.Option | click.core.Parameter,
- value: str | None,
-) -> ActiveFlagChoice | None:
- """
- Use as a callback for active flag options.
- """
- # This is to satisfy the "vulture" linter.
- del ctx
- del param
-
- if value is None:
- return None
-
- return ActiveFlagChoice(value=value)
-
-
+@beartype
def active_flag_option(
*,
allow_none: bool,
-) -> Callable[..., None]:
- """
- An option decorator for setting a target's active flag.
- """
+) -> Callable[..., Any]:
+ """An option decorator for setting a target's active flag."""
if allow_none:
default = None
show_default = False
@@ -110,14 +81,13 @@ def active_flag_option(
"--active-flag",
"active_flag_choice",
help="Whether or not the target is active for query.",
- type=click.Choice(choices=[item.value for item in ActiveFlagChoice]),
+ type=click.Choice(choices=ActiveFlagChoice, case_sensitive=False),
default=default,
- callback=_active_flag_choice_callback,
show_default=show_default,
)
-application_metadata_option: Callable[..., None] = click.option(
+application_metadata_option: Callable[..., Any] = click.option(
"--application-metadata",
type=str,
required=False,
diff --git a/src/vws_cli/options/timeout.py b/src/vws_cli/options/timeout.py
new file mode 100644
index 00000000..bf8bfad3
--- /dev/null
+++ b/src/vws_cli/options/timeout.py
@@ -0,0 +1,35 @@
+"""``click`` options regarding timeouts."""
+
+from collections.abc import Callable
+from typing import Any
+
+import click
+from beartype import beartype
+
+
+@beartype
+def connection_timeout_seconds_option(
+ command: Callable[..., Any],
+) -> Callable[..., Any]:
+ """An option decorator for the connection timeout."""
+ return click.option(
+ "--connection-timeout-seconds",
+ type=click.FloatRange(min=0.05),
+ default=30,
+ help="The connection timeout for HTTP requests, in seconds.",
+ show_default=True,
+ )(command)
+
+
+@beartype
+def read_timeout_seconds_option(
+ command: Callable[..., Any],
+) -> Callable[..., Any]:
+ """An option decorator for the read timeout."""
+ return click.option(
+ "--read-timeout-seconds",
+ type=click.FloatRange(min=0.05),
+ default=30,
+ help="The read timeout for HTTP requests, in seconds.",
+ show_default=True,
+ )(command)
diff --git a/src/vws_cli/options/vws.py b/src/vws_cli/options/vws.py
new file mode 100644
index 00000000..c944d58d
--- /dev/null
+++ b/src/vws_cli/options/vws.py
@@ -0,0 +1,39 @@
+"""``click`` options for VWS API options."""
+
+from collections.abc import Callable
+from typing import Any
+
+import click
+from beartype import beartype
+
+
+@beartype
+def database_id_option(
+ command: Callable[..., Any],
+) -> Callable[..., Any]:
+ """An option decorator for the Vuforia database ID."""
+ return click.option(
+ "--database-id",
+ type=str,
+ help=(
+ "The ID of the Vuforia database which the given server keys "
+ "belong to. This is shown in the Vuforia target manager."
+ ),
+ required=True,
+ envvar="VUFORIA_DATABASE_ID",
+ show_envvar=True,
+ )(command)
+
+
+@beartype
+def base_vws_url_option(
+ command: Callable[..., Any],
+) -> Callable[..., Any]:
+ """An option decorator for choosing the base VWS URL."""
+ return click.option(
+ "--base-vws-url",
+ type=click.STRING,
+ default="https://vws.vuforia.com",
+ help="The base URL for the VWS API.",
+ show_default=True,
+ )(command)
diff --git a/src/vws_cli/query.py b/src/vws_cli/query.py
index 29c27d96..e1648b77 100644
--- a/src/vws_cli/query.py
+++ b/src/vws_cli/query.py
@@ -1,14 +1,11 @@
-"""
-A CLI for the Vuforia Cloud Recognition Service API.
-"""
+"""A CLI for the Vuforia Cloud Recognition Service API."""
import contextlib
import dataclasses
import io
import sys
-from collections.abc import Callable, Iterator
+from collections.abc import Generator
from pathlib import Path
-from typing import Any
import click
import yaml
@@ -30,14 +27,16 @@
client_access_key_option,
client_secret_key_option,
)
+from vws_cli.options.timeout import (
+ connection_timeout_seconds_option,
+ read_timeout_seconds_option,
+)
@beartype
@contextlib.contextmanager
-def handle_vwq_exceptions() -> Iterator[None]:
- """
- Show error messages and catch exceptions from ``VWS-Python``.
- """
+def _handle_vwq_exceptions() -> Generator[None]:
+ """Show error messages and catch exceptions from ``VWS-Python``."""
try:
yield
except BadImageError:
@@ -66,127 +65,64 @@ def handle_vwq_exceptions() -> Iterator[None]:
sys.exit(1)
-@beartype
-def image_argument(command: Callable[..., None]) -> Callable[..., Any]:
- """
- An argument decorator for choosing a query image.
- """
- click_argument_function: Callable[
- [Callable[..., None]],
- Callable[..., None],
- ] = click.argument(
- "image",
- type=click.Path(
- exists=True,
- file_okay=True,
- dir_okay=False,
- path_type=Path,
- ),
- )
-
- function: Callable[..., None] = click_argument_function(command)
- return function
-
-
-def max_num_results_option(
- command: Callable[..., None],
-) -> Callable[..., None]:
- """
- An option decorator for choosing the maximum number of query results.
- """
- maximum = 50
- click_option_function: Callable[
- [Callable[..., None]],
- Callable[..., None],
- ] = click.option(
- "--max-num-results",
- type=click.IntRange(min=1, max=maximum),
- default=1,
- help=(
- "The maximum number of matching targets to be returned. "
- f"Must be <= {maximum}."
- ),
- show_default=True,
- )
-
- function: Callable[..., None] = click_option_function(command)
- return function
-
-
-def include_target_data_callback(
- ctx: click.core.Context,
- param: click.core.Option | click.core.Parameter,
- value: str,
-) -> CloudRecoIncludeTargetData:
- """
- Use as a callback for active flag options.
- """
- # This is to satisfy the "vulture" linter.
- del ctx
- del param
-
- return {
- "top": CloudRecoIncludeTargetData.TOP,
- "none": CloudRecoIncludeTargetData.NONE,
- "all": CloudRecoIncludeTargetData.ALL,
- }[value]
-
-
-def include_target_data_option(
- command: Callable[..., None],
-) -> Callable[..., None]:
- """
- An option decorator for choosing whether to include target data.
- """
- click_option_function: Callable[
- [Callable[..., None]],
- Callable[..., None],
- ] = click.option(
- "--include-target-data",
- type=click.Choice(choices=["top", "none", "all"], case_sensitive=True),
- default="top",
- callback=include_target_data_callback,
- help=(
- "Whether target_data records shall be returned for the matched "
- "targets. Accepted values are top (default value, only return "
- "target_data for top ranked match), none (return no target_data), "
- "all (for all matched targets)."
- ),
- show_default=True,
- )
+_image_argument = click.argument(
+ "image",
+ type=click.Path(
+ exists=True,
+ file_okay=True,
+ dir_okay=False,
+ path_type=Path,
+ ),
+)
- function: Callable[..., None] = click_option_function(command)
- return function
+_MAX_NUM_RESULTS_DEFAULT = 50
+_max_num_results_option = click.option(
+ "--max-num-results",
+ type=click.IntRange(min=1, max=_MAX_NUM_RESULTS_DEFAULT),
+ default=1,
+ help=(
+ "The maximum number of matching targets to be returned. "
+ f"Must be <= {_MAX_NUM_RESULTS_DEFAULT}."
+ ),
+ show_default=True,
+)
-@beartype
-def base_vwq_url_option(command: Callable[..., None]) -> Callable[..., None]:
- """
- An option decorator for choosing the base VWQ URL.
- """
- click_option_function: Callable[
- [Callable[..., None]],
- Callable[..., None],
- ] = click.option(
- "--base-vwq-url",
- type=click.STRING,
- default="https://cloudreco.vuforia.com",
- help="The base URL for the VWQ API.",
- show_default=True,
- )
+_include_target_data_option = click.option(
+ "--include-target-data",
+ type=click.Choice(
+ choices=CloudRecoIncludeTargetData,
+ case_sensitive=False,
+ ),
+ default=CloudRecoIncludeTargetData.TOP.lower(),
+ help=(
+ "Whether target_data records shall be returned for the matched "
+ "targets. Accepted values are top (default value, only return "
+ "target_data for top ranked match), none (return no target_data), "
+ "all (for all matched targets)."
+ ),
+ show_default=True,
+)
- function: Callable[..., None] = click_option_function(command)
- return function
+_base_vwq_url_option = click.option(
+ "--base-vwq-url",
+ type=click.STRING,
+ default="https://cloudreco.vuforia.com",
+ help="The base URL for the VWQ API.",
+ show_default=True,
+)
@click.command(name="vuforia-cloud-reco")
-@image_argument
+@_image_argument
@client_access_key_option
@client_secret_key_option
-@include_target_data_option
-@max_num_results_option
-@base_vwq_url_option
-@handle_vwq_exceptions()
+@_include_target_data_option
+@_max_num_results_option
+@_base_vwq_url_option
+@connection_timeout_seconds_option
+@read_timeout_seconds_option
+@_handle_vwq_exceptions()
# We set the ``version`` parameter because in PyInstaller binaries,
# ``pkg_resources`` is not available.
#
@@ -201,14 +137,18 @@ def vuforia_cloud_reco(
max_num_results: int,
include_target_data: CloudRecoIncludeTargetData,
base_vwq_url: str,
+ connection_timeout_seconds: float,
+ read_timeout_seconds: float,
) -> None:
- """
- Make a request to the Vuforia Cloud Recognition Service API.
- """
+ """Make a request to the Vuforia Cloud Recognition Service API."""
client = CloudRecoService(
client_access_key=client_access_key,
client_secret_key=client_secret_key,
base_vwq_url=base_vwq_url,
+ request_timeout_seconds=(
+ connection_timeout_seconds,
+ read_timeout_seconds,
+ ),
)
query_result = client.query(
image=io.BytesIO(initial_bytes=image.read_bytes()),
diff --git a/src/vws_cli/vumark.py b/src/vws_cli/vumark.py
new file mode 100644
index 00000000..8fadb8f1
--- /dev/null
+++ b/src/vws_cli/vumark.py
@@ -0,0 +1,156 @@
+"""``click`` command for VuMark generation."""
+
+import contextlib
+import sys
+from collections.abc import Generator
+from enum import StrEnum, unique
+from pathlib import Path
+
+import click
+from beartype import beartype
+from vws import VuMarkService
+from vws.exceptions.base_exceptions import VWSError
+from vws.exceptions.custom_exceptions import ServerError
+from vws.exceptions.vws_exceptions import (
+ InvalidInstanceIdError,
+ TargetStatusNotSuccessError,
+ UnknownTargetError,
+)
+from vws.vumark_accept import VuMarkAccept
+
+from vws_cli import __version__
+from vws_cli._error_handling import get_error_message
+from vws_cli.options.credentials import (
+ server_access_key_option,
+ server_secret_key_option,
+)
+from vws_cli.options.targets import target_id_option
+from vws_cli.options.timeout import (
+ connection_timeout_seconds_option,
+ read_timeout_seconds_option,
+)
+from vws_cli.options.vws import base_vws_url_option
+
+
+@beartype
+@unique
+class VuMarkFormatChoice(StrEnum):
+ """Choices for the VuMark output format."""
+
+ PNG = "png"
+ SVG = "svg"
+ PDF = "pdf"
+
+
+_FORMAT_CHOICE_TO_ACCEPT: dict[VuMarkFormatChoice, VuMarkAccept] = {
+ VuMarkFormatChoice.PNG: VuMarkAccept.PNG,
+ VuMarkFormatChoice.SVG: VuMarkAccept.SVG,
+ VuMarkFormatChoice.PDF: VuMarkAccept.PDF,
+}
+
+
+@beartype
+@contextlib.contextmanager
+def _handle_vumark_exceptions() -> Generator[None]:
+ """Show error messages and catch exceptions from ``VWS-Python``."""
+ error_message = ""
+
+ try:
+ yield
+ except (UnknownTargetError, TargetStatusNotSuccessError) as exc:
+ match exc:
+ case UnknownTargetError():
+ error_message = (
+ f'Error: Target "{exc.target_id}" does not exist.'
+ )
+ case _:
+ error_message = (
+ f'Error: The target "{exc.target_id}" is not in the '
+ "success state and cannot be used to generate a "
+ "VuMark instance."
+ )
+ except (VWSError, ServerError) as exc:
+ match exc:
+ case InvalidInstanceIdError():
+ error_message = "Error: The given instance ID is invalid."
+ case _:
+ error_message = get_error_message(exc=exc)
+ else:
+ return
+
+ click.echo(message=error_message, err=True)
+ sys.exit(1)
+
+
+@click.command(name="vumark")
+@server_access_key_option
+@server_secret_key_option
+@target_id_option
+@click.option(
+ "--instance-id",
+ type=str,
+ required=True,
+ help="The instance ID to encode in the VuMark.",
+)
+@click.option(
+ "--format",
+ "format_choice",
+ type=click.Choice(choices=VuMarkFormatChoice, case_sensitive=False),
+ default=VuMarkFormatChoice.PNG.lower(),
+ help="The output format for the generated VuMark.",
+ show_default=True,
+)
+@click.option(
+ "--output",
+ "output_file_path",
+ type=click.Path(
+ dir_okay=False,
+ writable=True,
+ path_type=Path,
+ ),
+ required=True,
+ help="The path to write the generated VuMark to.",
+)
+@_handle_vumark_exceptions()
+@base_vws_url_option
+@connection_timeout_seconds_option
+@read_timeout_seconds_option
+@click.version_option(version=__version__)
+@beartype
+def generate_vumark(
+ *,
+ server_access_key: str,
+ server_secret_key: str,
+ target_id: str,
+ instance_id: str,
+ format_choice: VuMarkFormatChoice,
+ output_file_path: Path,
+ base_vws_url: str,
+ connection_timeout_seconds: float,
+ read_timeout_seconds: float,
+) -> None:
+ """Generate a VuMark instance.
+
+ \b
+ See
+ https://developer.vuforia.com/library/vuforia-engine/web-api/vumark-generation-web-api/
+ """
+ vumark_client = VuMarkService(
+ server_access_key=server_access_key,
+ server_secret_key=server_secret_key,
+ base_vws_url=base_vws_url,
+ request_timeout_seconds=(
+ connection_timeout_seconds,
+ read_timeout_seconds,
+ ),
+ )
+
+ accept = _FORMAT_CHOICE_TO_ACCEPT[format_choice]
+
+ vumark_data = vumark_client.generate_vumark_instance(
+ target_id=target_id,
+ instance_id=instance_id,
+ accept=accept,
+ )
+
+ output_file_path.write_bytes(data=vumark_data)
diff --git a/tests/__init__.py b/tests/__init__.py
index 76356810..fb30f6f7 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,3 +1 @@
-"""
-Tests for the VWS CLI.
-"""
+"""Tests for the VWS CLI."""
diff --git a/tests/conftest.py b/tests/conftest.py
index 21899814..836a94d6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,56 +1,56 @@
-"""
-``pytest`` fixtures.
-"""
+"""``pytest`` fixtures."""
from collections.abc import Iterator
import pytest
-from beartype import beartype
from mock_vws import MockVWS
-from mock_vws.database import VuforiaDatabase
+from mock_vws.database import CloudDatabase, VuMarkDatabase
+from mock_vws.target import VuMarkTarget
from vws import VWS, CloudRecoService
-@beartype
-def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
- """
- Apply the beartype decorator to all collected test functions.
- """
- for item in items:
- assert isinstance(item, pytest.Function)
- item.obj = beartype(obj=item.obj)
-
-
-@beartype
@pytest.fixture(name="mock_database")
-def fixture_mock_database() -> Iterator[VuforiaDatabase]:
- """
- Yield a mock ``VuforiaDatabase``.
- """
+def fixture_mock_database() -> Iterator[CloudDatabase]:
+ """Yield a mock ``CloudDatabase``."""
with MockVWS() as mock:
- database = VuforiaDatabase()
- mock.add_database(database=database)
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
yield database
-@beartype
@pytest.fixture
-def vws_client(mock_database: VuforiaDatabase) -> VWS:
- """
- Return a VWS client which connects to a mock database.
- """
+def vws_client(*, mock_database: CloudDatabase) -> VWS:
+ """Return a VWS client which connects to a mock database."""
return VWS(
server_access_key=mock_database.server_access_key,
server_secret_key=mock_database.server_secret_key,
)
+@pytest.fixture(name="vumark_database")
+def fixture_vumark_database() -> Iterator[VuMarkDatabase]:
+ """Yield a mock ``VuMarkDatabase`` with one pre-created target."""
+ vumark_target = VuMarkTarget(name="test-vumark-target")
+ database = VuMarkDatabase(vumark_targets={vumark_target})
+ with MockVWS() as mock:
+ mock.add_vumark_database(vumark_database=database)
+ yield database
+
+
+@pytest.fixture(name="vumark_target")
+def fixture_vumark_target(*, vumark_database: VuMarkDatabase) -> VuMarkTarget:
+ """Return the pre-created ``VuMarkTarget`` in the database."""
+ return next(iter(vumark_database.not_deleted_targets))
+
+
@pytest.fixture
def cloud_reco_client(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
) -> CloudRecoService:
"""
- Return a ``CloudRecoService`` client which connects to a mock database.
+ Return a ``CloudRecoService`` client which connects to a mock
+ database.
"""
return CloudRecoService(
client_access_key=mock_database.client_access_key,
diff --git a/tests/test_cli.py b/tests/test_cli.py
index f830dd46..a8d726d6 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,6 +1,4 @@
-"""
-Tests for the VWS CLI.
-"""
+"""Tests for the VWS CLI."""
from click.testing import CliRunner
@@ -8,9 +6,7 @@
def test_version() -> None:
- """
- The CLI version is shown with ``vws --version``.
- """
+ """The CLI version is shown with ``vws --version``."""
runner = CliRunner()
result = runner.invoke(
cli=vws_group,
diff --git a/tests/test_help.py b/tests/test_help.py
index 9d782a01..a9e8647d 100644
--- a/tests/test_help.py
+++ b/tests/test_help.py
@@ -1,6 +1,4 @@
-"""
-Tests for the VWS CLI help.
-"""
+"""Tests for the VWS CLI help."""
import pytest
from click.testing import CliRunner
@@ -8,6 +6,7 @@
from vws_cli import vws_group
from vws_cli.query import vuforia_cloud_reco
+from vws_cli.vumark import generate_vumark
_SUBCOMMANDS = [[item] for item in vws_group.commands]
_BASE_COMMAND: list[list[str]] = [[]]
@@ -20,6 +19,7 @@
ids=[str(object=cmd) for cmd in _COMMANDS],
)
def test_vws_command_help(
+ *,
command: list[str],
file_regression: FileRegressionFixture,
) -> None:
@@ -40,6 +40,24 @@ def test_vws_command_help(
file_regression.check(contents=result.output)
+def test_vumark_help(file_regression: FileRegressionFixture) -> None:
+ """Expected help text is shown for the ``vumark`` command.
+
+ This help text is defined in files.
+ To update these files, run ``pytest`` with the ``--regen-all`` flag.
+ """
+ runner = CliRunner()
+ arguments = ["--help"]
+ result = runner.invoke(
+ cli=generate_vumark,
+ args=arguments,
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 0
+ file_regression.check(contents=result.output)
+
+
def test_query_help(file_regression: FileRegressionFixture) -> None:
"""Expected help text is shown for the ``vuforia-cloud-reco`` command.
diff --git a/tests/test_help/test_query_help.txt b/tests/test_help/test_query_help.txt
index 21f3cd7f..713550da 100644
--- a/tests/test_help/test_query_help.txt
+++ b/tests/test_help/test_query_help.txt
@@ -22,5 +22,11 @@ Options:
1<=x<=50]
--base-vwq-url TEXT The base URL for the VWQ API. [default:
https://cloudreco.vuforia.com]
+ --connection-timeout-seconds FLOAT RANGE
+ The connection timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ --read-timeout-seconds FLOAT RANGE
+ The read timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
--version Show the version and exit.
--help Show this message and exit.
diff --git a/tests/test_help/test_vumark_help.txt b/tests/test_help/test_vumark_help.txt
new file mode 100644
index 00000000..d5ab6ba8
--- /dev/null
+++ b/tests/test_help/test_vumark_help.txt
@@ -0,0 +1,32 @@
+Usage: vumark [OPTIONS]
+
+ Generate a VuMark instance.
+
+ See
+ https://developer.vuforia.com/library/vuforia-engine/web-api/vumark-generation-web-api/
+
+Options:
+ --server-access-key TEXT A Vuforia server access key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_ACCESS_KEY; required]
+ --server-secret-key TEXT A Vuforia server secret key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_SECRET_KEY; required]
+ --target-id TEXT The ID of a target in the Vuforia database.
+ [required]
+ --instance-id TEXT The instance ID to encode in the VuMark.
+ [required]
+ --format [png|svg|pdf] The output format for the generated VuMark.
+ [default: png]
+ --output FILE The path to write the generated VuMark to.
+ [required]
+ --base-vws-url TEXT The base URL for the VWS API. [default:
+ https://vws.vuforia.com]
+ --connection-timeout-seconds FLOAT RANGE
+ The connection timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ --read-timeout-seconds FLOAT RANGE
+ The read timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ --version Show the version and exit.
+ --help Show this message and exit.
diff --git a/tests/test_help/test_vws_command_help____.txt b/tests/test_help/test_vws_command_help____.txt
index 13ff9f18..c757a12b 100644
--- a/tests/test_help/test_vws_command_help____.txt
+++ b/tests/test_help/test_vws_command_help____.txt
@@ -7,12 +7,21 @@ Options:
-h, --help Show this message and exit.
Commands:
- add-target Add a target.
- delete-target Delete a target.
- get-database-summary-report Get a database summary report.
- get-duplicate-targets Get a list of potential duplicate targets.
- get-target-record Get a target record.
- get-target-summary-report Get a target summary report.
- list-targets List targets.
- update-target Update a target.
- wait-for-target-processed Wait for a target to be "processed".
+ add-target Add a target.
+ create-model-target-dataset Create a Model Target dataset.
+ delete-model-target-dataset Delete a Model Target dataset.
+ delete-target Delete a target.
+ download-model-target-dataset Download a generated Model...
+ get-database-reco-counts-report
+ Get a per-target recognition...
+ get-database-summary-report Get a database summary report.
+ get-duplicate-targets Get a list of potential...
+ get-model-target-dataset-status
+ Get the status of a Model Target...
+ get-target-record Get a target record.
+ get-target-summary-report Get a target summary report.
+ list-targets List targets.
+ update-target Update a target.
+ wait-for-model-target-dataset-generated
+ Wait for Vuforia to finish...
+ wait-for-target-processed Wait for a target to be...
diff --git a/tests/test_help/test_vws_command_help___add_target___.txt b/tests/test_help/test_vws_command_help___add_target___.txt
index 02446251..d7b4c9ad 100644
--- a/tests/test_help/test_vws_command_help___add_target___.txt
+++ b/tests/test_help/test_vws_command_help___add_target___.txt
@@ -6,22 +6,28 @@ Usage: vws add-target [OPTIONS]
https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#add
Options:
- --width FLOAT The width of the target in the Vuforia database.
- [required]
- --application-metadata TEXT The base64 encoded application metadata
- associated with the target.
- --server-access-key TEXT A Vuforia server access key to use to access the
- Vuforia Web Services API. [env var:
- VUFORIA_SERVER_ACCESS_KEY; required]
- --server-secret-key TEXT A Vuforia server secret key to use to access the
- Vuforia Web Services API. [env var:
- VUFORIA_SERVER_SECRET_KEY; required]
- --name TEXT The name of the target in the Vuforia database.
- [required]
- --image FILE The path to an image to upload and set as the
- target image. [required]
- --active-flag [true|false] Whether or not the target is active for query.
- [default: true]
- --base-vws-url TEXT The base URL for the VWS API. [default:
- https://vws.vuforia.com]
- -h, --help Show this message and exit.
+ --width FLOAT The width of the target in the Vuforia
+ database. [required]
+ --application-metadata TEXT The base64 encoded application metadata
+ associated with the target.
+ --server-access-key TEXT A Vuforia server access key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_ACCESS_KEY; required]
+ --server-secret-key TEXT A Vuforia server secret key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_SECRET_KEY; required]
+ --name TEXT The name of the target in the Vuforia
+ database. [required]
+ --image FILE The path to an image to upload and set as the
+ target image. [required]
+ --active-flag [true|false] Whether or not the target is active for query.
+ [default: true]
+ --base-vws-url TEXT The base URL for the VWS API. [default:
+ https://vws.vuforia.com]
+ --connection-timeout-seconds FLOAT RANGE
+ The connection timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ --read-timeout-seconds FLOAT RANGE
+ The read timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ -h, --help Show this message and exit.
diff --git a/tests/test_help/test_vws_command_help___create_model_target_dataset___.txt b/tests/test_help/test_vws_command_help___create_model_target_dataset___.txt
new file mode 100644
index 00000000..8654c49a
--- /dev/null
+++ b/tests/test_help/test_vws_command_help___create_model_target_dataset___.txt
@@ -0,0 +1,73 @@
+Usage: vws create-model-target-dataset [OPTIONS]
+
+ Create a Model Target dataset.
+
+ Vuforia generates the dataset in the background, so the dataset is not
+ available to download immediately. The UUID of the new dataset is shown.
+
+ See
+ https://developer.vuforia.com/library/vuforia-engine/web-api/model-target-web-api/
+
+Options:
+ --name TEXT The name of the Model Target dataset.
+ [required]
+ --target-sdk TEXT The Vuforia Engine version to generate the
+ dataset for, for example "10.29". [required]
+ --dataset-type [standard|advanced]
+ The kind of Model Target dataset. Standard and
+ advanced datasets are separate resources, so a
+ dataset created as one type is not visible to
+ requests for the other type. [default:
+ standard]
+ --model-name TEXT The name of the model to generate the dataset
+ from. This is required unless --models-file is
+ given.
+ --cad-data-url TEXT A URL which Vuforia downloads the model's CAD
+ data from.
+ --cad-data-file FILE The path to a file with the model's CAD data.
+ The file is sent to Vuforia as base64 encoded
+ data.
+ --automatic-coloring [always|auto|never]
+ Whether Vuforia colors the model
+ automatically.
+ --cad-data-format [dae|fbx|glb|iges|obj|pvz|stl|vrml|zip]
+ The format of the model's CAD data.
+ --motion-hint [adaptive|dynamic|static]
+ How the object which the model represents
+ moves.
+ --optimize-tracking-for [ar_controller|default|low_feature_objects]
+ What Vuforia optimizes tracking of the model
+ for.
+ --realistic-appearance [auto|false|true]
+ Whether the model looks like the object it
+ represents. This is documented for advanced
+ datasets only.
+ --simplify [always|auto|never] Whether Vuforia simplifies the model.
+ --tracking-mode [car|default|scan]
+ The tracking mode to generate the dataset for.
+ --state-based-configuration-file FILE
+ The path to a JSON file with the model's
+ State-Based Model Target configuration.
+ --models-file FILE The path to a JSON file which describes the
+ models to generate the dataset from, in the
+ form which the Model Target Web API takes. The
+ file holds either an array of models or an
+ object with a "models" array. This is the only
+ way to give multiple models, or to give guide
+ views, and it cannot be used with the other
+ model options.
+ --client-id TEXT A Vuforia OAuth2 client ID to use to access
+ the Model Target Web API. [env var:
+ VUFORIA_MODEL_TARGET_CLIENT_ID; required]
+ --client-secret TEXT A Vuforia OAuth2 client secret to use to
+ access the Model Target Web API. [env var:
+ VUFORIA_MODEL_TARGET_CLIENT_SECRET; required]
+ --base-vws-url TEXT The base URL for the VWS API. [default:
+ https://vws.vuforia.com]
+ --connection-timeout-seconds FLOAT RANGE
+ The connection timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ --read-timeout-seconds FLOAT RANGE
+ The read timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ -h, --help Show this message and exit.
diff --git a/tests/test_help/test_vws_command_help___delete_model_target_dataset___.txt b/tests/test_help/test_vws_command_help___delete_model_target_dataset___.txt
new file mode 100644
index 00000000..a1c91d10
--- /dev/null
+++ b/tests/test_help/test_vws_command_help___delete_model_target_dataset___.txt
@@ -0,0 +1,31 @@
+Usage: vws delete-model-target-dataset [OPTIONS]
+
+ Delete a Model Target dataset.
+
+ See
+ https://developer.vuforia.com/library/vuforia-engine/web-api/model-target-web-api/
+
+Options:
+ --dataset-uuid TEXT The UUID of a Model Target dataset, as given
+ when the dataset was created. [required]
+ --dataset-type [standard|advanced]
+ The kind of Model Target dataset. Standard and
+ advanced datasets are separate resources, so a
+ dataset created as one type is not visible to
+ requests for the other type. [default:
+ standard]
+ --client-id TEXT A Vuforia OAuth2 client ID to use to access
+ the Model Target Web API. [env var:
+ VUFORIA_MODEL_TARGET_CLIENT_ID; required]
+ --client-secret TEXT A Vuforia OAuth2 client secret to use to
+ access the Model Target Web API. [env var:
+ VUFORIA_MODEL_TARGET_CLIENT_SECRET; required]
+ --base-vws-url TEXT The base URL for the VWS API. [default:
+ https://vws.vuforia.com]
+ --connection-timeout-seconds FLOAT RANGE
+ The connection timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ --read-timeout-seconds FLOAT RANGE
+ The read timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ -h, --help Show this message and exit.
diff --git a/tests/test_help/test_vws_command_help___delete_target___.txt b/tests/test_help/test_vws_command_help___delete_target___.txt
index 472b6d30..8862a48a 100644
--- a/tests/test_help/test_vws_command_help___delete_target___.txt
+++ b/tests/test_help/test_vws_command_help___delete_target___.txt
@@ -6,14 +6,20 @@ Usage: vws delete-target [OPTIONS]
https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#delete.
Options:
- --server-access-key TEXT A Vuforia server access key to use to access the
- Vuforia Web Services API. [env var:
- VUFORIA_SERVER_ACCESS_KEY; required]
- --server-secret-key TEXT A Vuforia server secret key to use to access the
- Vuforia Web Services API. [env var:
- VUFORIA_SERVER_SECRET_KEY; required]
- --target-id TEXT The ID of a target in the Vuforia database.
- [required]
- --base-vws-url TEXT The base URL for the VWS API. [default:
- https://vws.vuforia.com]
- -h, --help Show this message and exit.
+ --server-access-key TEXT A Vuforia server access key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_ACCESS_KEY; required]
+ --server-secret-key TEXT A Vuforia server secret key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_SECRET_KEY; required]
+ --target-id TEXT The ID of a target in the Vuforia database.
+ [required]
+ --base-vws-url TEXT The base URL for the VWS API. [default:
+ https://vws.vuforia.com]
+ --connection-timeout-seconds FLOAT RANGE
+ The connection timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ --read-timeout-seconds FLOAT RANGE
+ The read timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ -h, --help Show this message and exit.
diff --git a/tests/test_help/test_vws_command_help___download_model_target_dataset___.txt b/tests/test_help/test_vws_command_help___download_model_target_dataset___.txt
new file mode 100644
index 00000000..ec05b8db
--- /dev/null
+++ b/tests/test_help/test_vws_command_help___download_model_target_dataset___.txt
@@ -0,0 +1,33 @@
+Usage: vws download-model-target-dataset [OPTIONS]
+
+ Download a generated Model Target dataset.
+
+ See
+ https://developer.vuforia.com/library/vuforia-engine/web-api/model-target-web-api/
+
+Options:
+ --output FILE The path to write the generated dataset zip
+ file to. [required]
+ --dataset-uuid TEXT The UUID of a Model Target dataset, as given
+ when the dataset was created. [required]
+ --dataset-type [standard|advanced]
+ The kind of Model Target dataset. Standard and
+ advanced datasets are separate resources, so a
+ dataset created as one type is not visible to
+ requests for the other type. [default:
+ standard]
+ --client-id TEXT A Vuforia OAuth2 client ID to use to access
+ the Model Target Web API. [env var:
+ VUFORIA_MODEL_TARGET_CLIENT_ID; required]
+ --client-secret TEXT A Vuforia OAuth2 client secret to use to
+ access the Model Target Web API. [env var:
+ VUFORIA_MODEL_TARGET_CLIENT_SECRET; required]
+ --base-vws-url TEXT The base URL for the VWS API. [default:
+ https://vws.vuforia.com]
+ --connection-timeout-seconds FLOAT RANGE
+ The connection timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ --read-timeout-seconds FLOAT RANGE
+ The read timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ -h, --help Show this message and exit.
diff --git a/tests/test_help/test_vws_command_help___get_database_reco_counts_report___.txt b/tests/test_help/test_vws_command_help___get_database_reco_counts_report___.txt
new file mode 100644
index 00000000..2cd38898
--- /dev/null
+++ b/tests/test_help/test_vws_command_help___get_database_reco_counts_report___.txt
@@ -0,0 +1,50 @@
+Usage: vws get-database-reco-counts-report [OPTIONS]
+
+ Get a per-target recognition counts report for a database.
+
+ The report is a CSV with a ``target_id,reco_count`` header. Vuforia generates
+ the report in the background, so by default we wait for the report to be
+ generated before downloading it.
+
+ See
+ https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api.
+
+Options:
+ --month TEXT The month to get recognition counts for, in
+ the YYYY-mm form. Vuforia accepts only the
+ current month and the previous month.
+ [default: (the current month)]
+ --output FILE The path to write the CSV report to. By
+ default, the report is written to stdout.
+ --no-wait Do not wait for the report to be generated.
+ Instead, show the URL to download the report
+ from once it has been generated.
+ --seconds-between-requests FLOAT RANGE
+ The number of seconds to wait between requests
+ made while polling for the report. We wait 0.2
+ seconds by default, rather than less, than
+ that to decrease the number of calls made to
+ the API, to decrease the likelihood of hitting
+ the request quota. [default: 0.2; x>=0.05]
+ --timeout-seconds FLOAT RANGE The maximum number of seconds to wait for the
+ report to be generated. [default: 300;
+ x>=0.05]
+ --server-access-key TEXT A Vuforia server access key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_ACCESS_KEY; required]
+ --server-secret-key TEXT A Vuforia server secret key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_SECRET_KEY; required]
+ --database-id TEXT The ID of the Vuforia database which the given
+ server keys belong to. This is shown in the
+ Vuforia target manager. [env var:
+ VUFORIA_DATABASE_ID; required]
+ --base-vws-url TEXT The base URL for the VWS API. [default:
+ https://vws.vuforia.com]
+ --connection-timeout-seconds FLOAT RANGE
+ The connection timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ --read-timeout-seconds FLOAT RANGE
+ The read timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ -h, --help Show this message and exit.
diff --git a/tests/test_help/test_vws_command_help___get_database_summary_report___.txt b/tests/test_help/test_vws_command_help___get_database_summary_report___.txt
index 959d6a22..e6f42a6a 100644
--- a/tests/test_help/test_vws_command_help___get_database_summary_report___.txt
+++ b/tests/test_help/test_vws_command_help___get_database_summary_report___.txt
@@ -6,12 +6,18 @@ Usage: vws get-database-summary-report [OPTIONS]
https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report.
Options:
- --server-access-key TEXT A Vuforia server access key to use to access the
- Vuforia Web Services API. [env var:
- VUFORIA_SERVER_ACCESS_KEY; required]
- --server-secret-key TEXT A Vuforia server secret key to use to access the
- Vuforia Web Services API. [env var:
- VUFORIA_SERVER_SECRET_KEY; required]
- --base-vws-url TEXT The base URL for the VWS API. [default:
- https://vws.vuforia.com]
- -h, --help Show this message and exit.
+ --server-access-key TEXT A Vuforia server access key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_ACCESS_KEY; required]
+ --server-secret-key TEXT A Vuforia server secret key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_SECRET_KEY; required]
+ --base-vws-url TEXT The base URL for the VWS API. [default:
+ https://vws.vuforia.com]
+ --connection-timeout-seconds FLOAT RANGE
+ The connection timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ --read-timeout-seconds FLOAT RANGE
+ The read timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ -h, --help Show this message and exit.
diff --git a/tests/test_help/test_vws_command_help___get_duplicate_targets___.txt b/tests/test_help/test_vws_command_help___get_duplicate_targets___.txt
index b378f3c5..4e406a48 100644
--- a/tests/test_help/test_vws_command_help___get_duplicate_targets___.txt
+++ b/tests/test_help/test_vws_command_help___get_duplicate_targets___.txt
@@ -6,14 +6,20 @@ Usage: vws get-duplicate-targets [OPTIONS]
https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#check.
Options:
- --server-access-key TEXT A Vuforia server access key to use to access the
- Vuforia Web Services API. [env var:
- VUFORIA_SERVER_ACCESS_KEY; required]
- --server-secret-key TEXT A Vuforia server secret key to use to access the
- Vuforia Web Services API. [env var:
- VUFORIA_SERVER_SECRET_KEY; required]
- --target-id TEXT The ID of a target in the Vuforia database.
- [required]
- --base-vws-url TEXT The base URL for the VWS API. [default:
- https://vws.vuforia.com]
- -h, --help Show this message and exit.
+ --server-access-key TEXT A Vuforia server access key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_ACCESS_KEY; required]
+ --server-secret-key TEXT A Vuforia server secret key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_SECRET_KEY; required]
+ --target-id TEXT The ID of a target in the Vuforia database.
+ [required]
+ --base-vws-url TEXT The base URL for the VWS API. [default:
+ https://vws.vuforia.com]
+ --connection-timeout-seconds FLOAT RANGE
+ The connection timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ --read-timeout-seconds FLOAT RANGE
+ The read timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ -h, --help Show this message and exit.
diff --git a/tests/test_help/test_vws_command_help___get_model_target_dataset_status___.txt b/tests/test_help/test_vws_command_help___get_model_target_dataset_status___.txt
new file mode 100644
index 00000000..245d6a95
--- /dev/null
+++ b/tests/test_help/test_vws_command_help___get_model_target_dataset_status___.txt
@@ -0,0 +1,31 @@
+Usage: vws get-model-target-dataset-status [OPTIONS]
+
+ Get the status of a Model Target dataset.
+
+ See
+ https://developer.vuforia.com/library/vuforia-engine/web-api/model-target-web-api/
+
+Options:
+ --dataset-uuid TEXT The UUID of a Model Target dataset, as given
+ when the dataset was created. [required]
+ --dataset-type [standard|advanced]
+ The kind of Model Target dataset. Standard and
+ advanced datasets are separate resources, so a
+ dataset created as one type is not visible to
+ requests for the other type. [default:
+ standard]
+ --client-id TEXT A Vuforia OAuth2 client ID to use to access
+ the Model Target Web API. [env var:
+ VUFORIA_MODEL_TARGET_CLIENT_ID; required]
+ --client-secret TEXT A Vuforia OAuth2 client secret to use to
+ access the Model Target Web API. [env var:
+ VUFORIA_MODEL_TARGET_CLIENT_SECRET; required]
+ --base-vws-url TEXT The base URL for the VWS API. [default:
+ https://vws.vuforia.com]
+ --connection-timeout-seconds FLOAT RANGE
+ The connection timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ --read-timeout-seconds FLOAT RANGE
+ The read timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ -h, --help Show this message and exit.
diff --git a/tests/test_help/test_vws_command_help___get_target_record___.txt b/tests/test_help/test_vws_command_help___get_target_record___.txt
index 173b6deb..1896cd69 100644
--- a/tests/test_help/test_vws_command_help___get_target_record___.txt
+++ b/tests/test_help/test_vws_command_help___get_target_record___.txt
@@ -6,14 +6,20 @@ Usage: vws get-target-record [OPTIONS]
https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record.
Options:
- --server-access-key TEXT A Vuforia server access key to use to access the
- Vuforia Web Services API. [env var:
- VUFORIA_SERVER_ACCESS_KEY; required]
- --server-secret-key TEXT A Vuforia server secret key to use to access the
- Vuforia Web Services API. [env var:
- VUFORIA_SERVER_SECRET_KEY; required]
- --target-id TEXT The ID of a target in the Vuforia database.
- [required]
- --base-vws-url TEXT The base URL for the VWS API. [default:
- https://vws.vuforia.com]
- -h, --help Show this message and exit.
+ --server-access-key TEXT A Vuforia server access key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_ACCESS_KEY; required]
+ --server-secret-key TEXT A Vuforia server secret key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_SECRET_KEY; required]
+ --target-id TEXT The ID of a target in the Vuforia database.
+ [required]
+ --base-vws-url TEXT The base URL for the VWS API. [default:
+ https://vws.vuforia.com]
+ --connection-timeout-seconds FLOAT RANGE
+ The connection timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ --read-timeout-seconds FLOAT RANGE
+ The read timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ -h, --help Show this message and exit.
diff --git a/tests/test_help/test_vws_command_help___get_target_summary_report___.txt b/tests/test_help/test_vws_command_help___get_target_summary_report___.txt
index 588ba302..1ec2e61c 100644
--- a/tests/test_help/test_vws_command_help___get_target_summary_report___.txt
+++ b/tests/test_help/test_vws_command_help___get_target_summary_report___.txt
@@ -6,14 +6,20 @@ Usage: vws get-target-summary-report [OPTIONS]
https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#retrieve-report.
Options:
- --server-access-key TEXT A Vuforia server access key to use to access the
- Vuforia Web Services API. [env var:
- VUFORIA_SERVER_ACCESS_KEY; required]
- --server-secret-key TEXT A Vuforia server secret key to use to access the
- Vuforia Web Services API. [env var:
- VUFORIA_SERVER_SECRET_KEY; required]
- --target-id TEXT The ID of a target in the Vuforia database.
- [required]
- --base-vws-url TEXT The base URL for the VWS API. [default:
- https://vws.vuforia.com]
- -h, --help Show this message and exit.
+ --server-access-key TEXT A Vuforia server access key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_ACCESS_KEY; required]
+ --server-secret-key TEXT A Vuforia server secret key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_SECRET_KEY; required]
+ --target-id TEXT The ID of a target in the Vuforia database.
+ [required]
+ --base-vws-url TEXT The base URL for the VWS API. [default:
+ https://vws.vuforia.com]
+ --connection-timeout-seconds FLOAT RANGE
+ The connection timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ --read-timeout-seconds FLOAT RANGE
+ The read timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ -h, --help Show this message and exit.
diff --git a/tests/test_help/test_vws_command_help___list_targets___.txt b/tests/test_help/test_vws_command_help___list_targets___.txt
index 358068ff..9df9f166 100644
--- a/tests/test_help/test_vws_command_help___list_targets___.txt
+++ b/tests/test_help/test_vws_command_help___list_targets___.txt
@@ -6,12 +6,18 @@ Usage: vws list-targets [OPTIONS]
https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#details-list.
Options:
- --server-access-key TEXT A Vuforia server access key to use to access the
- Vuforia Web Services API. [env var:
- VUFORIA_SERVER_ACCESS_KEY; required]
- --server-secret-key TEXT A Vuforia server secret key to use to access the
- Vuforia Web Services API. [env var:
- VUFORIA_SERVER_SECRET_KEY; required]
- --base-vws-url TEXT The base URL for the VWS API. [default:
- https://vws.vuforia.com]
- -h, --help Show this message and exit.
+ --server-access-key TEXT A Vuforia server access key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_ACCESS_KEY; required]
+ --server-secret-key TEXT A Vuforia server secret key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_SECRET_KEY; required]
+ --base-vws-url TEXT The base URL for the VWS API. [default:
+ https://vws.vuforia.com]
+ --connection-timeout-seconds FLOAT RANGE
+ The connection timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ --read-timeout-seconds FLOAT RANGE
+ The read timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ -h, --help Show this message and exit.
diff --git a/tests/test_help/test_vws_command_help___update_target___.txt b/tests/test_help/test_vws_command_help___update_target___.txt
index c756d7b8..032a1567 100644
--- a/tests/test_help/test_vws_command_help___update_target___.txt
+++ b/tests/test_help/test_vws_command_help___update_target___.txt
@@ -6,21 +6,29 @@ Usage: vws update-target [OPTIONS]
https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#update
Options:
- --width FLOAT The width of the target in the Vuforia database.
- --application-metadata TEXT The base64 encoded application metadata
- associated with the target.
- --server-access-key TEXT A Vuforia server access key to use to access the
- Vuforia Web Services API. [env var:
- VUFORIA_SERVER_ACCESS_KEY; required]
- --server-secret-key TEXT A Vuforia server secret key to use to access the
- Vuforia Web Services API. [env var:
- VUFORIA_SERVER_SECRET_KEY; required]
- --name TEXT The name of the target in the Vuforia database.
- --image FILE The path to an image to upload and set as the
- target image.
- --active-flag [true|false] Whether or not the target is active for query.
- --target-id TEXT The ID of a target in the Vuforia database.
- [required]
- --base-vws-url TEXT The base URL for the VWS API. [default:
- https://vws.vuforia.com]
- -h, --help Show this message and exit.
+ --width FLOAT The width of the target in the Vuforia
+ database.
+ --application-metadata TEXT The base64 encoded application metadata
+ associated with the target.
+ --server-access-key TEXT A Vuforia server access key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_ACCESS_KEY; required]
+ --server-secret-key TEXT A Vuforia server secret key to use to access
+ the Vuforia Web Services API. [env var:
+ VUFORIA_SERVER_SECRET_KEY; required]
+ --name TEXT The name of the target in the Vuforia
+ database.
+ --image FILE The path to an image to upload and set as the
+ target image.
+ --active-flag [true|false] Whether or not the target is active for query.
+ --target-id TEXT The ID of a target in the Vuforia database.
+ [required]
+ --base-vws-url TEXT The base URL for the VWS API. [default:
+ https://vws.vuforia.com]
+ --connection-timeout-seconds FLOAT RANGE
+ The connection timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ --read-timeout-seconds FLOAT RANGE
+ The read timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ -h, --help Show this message and exit.
diff --git a/tests/test_help/test_vws_command_help___wait_for_model_target_dataset_generated___.txt b/tests/test_help/test_vws_command_help___wait_for_model_target_dataset_generated___.txt
new file mode 100644
index 00000000..bdb85832
--- /dev/null
+++ b/tests/test_help/test_vws_command_help___wait_for_model_target_dataset_generated___.txt
@@ -0,0 +1,42 @@
+Usage: vws wait-for-model-target-dataset-generated [OPTIONS]
+
+ Wait for Vuforia to finish generating a Model Target dataset.
+
+ This is done by polling the Model Target Web API. The status of the dataset is
+ shown once Vuforia has finished with it. A dataset which failed to generate is
+ also finished, and gives a non-zero exit code.
+
+Options:
+ --seconds-between-requests FLOAT RANGE
+ The number of seconds to wait between requests
+ made while polling the dataset status. We wait
+ 0.2 seconds by default, rather than less, than
+ that to decrease the number of calls made to
+ the API, to decrease the likelihood of hitting
+ the request quota. [default: 0.2; x>=0.05]
+ --timeout-seconds FLOAT RANGE The maximum number of seconds to wait for the
+ dataset to be generated. [default: 300;
+ x>=0.05]
+ --dataset-uuid TEXT The UUID of a Model Target dataset, as given
+ when the dataset was created. [required]
+ --dataset-type [standard|advanced]
+ The kind of Model Target dataset. Standard and
+ advanced datasets are separate resources, so a
+ dataset created as one type is not visible to
+ requests for the other type. [default:
+ standard]
+ --client-id TEXT A Vuforia OAuth2 client ID to use to access
+ the Model Target Web API. [env var:
+ VUFORIA_MODEL_TARGET_CLIENT_ID; required]
+ --client-secret TEXT A Vuforia OAuth2 client secret to use to
+ access the Model Target Web API. [env var:
+ VUFORIA_MODEL_TARGET_CLIENT_SECRET; required]
+ --base-vws-url TEXT The base URL for the VWS API. [default:
+ https://vws.vuforia.com]
+ --connection-timeout-seconds FLOAT RANGE
+ The connection timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ --read-timeout-seconds FLOAT RANGE
+ The read timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ -h, --help Show this message and exit.
diff --git a/tests/test_help/test_vws_command_help___wait_for_target_processed___.txt b/tests/test_help/test_vws_command_help___wait_for_target_processed___.txt
index 7b07cbc0..9e212530 100644
--- a/tests/test_help/test_vws_command_help___wait_for_target_processed___.txt
+++ b/tests/test_help/test_vws_command_help___wait_for_target_processed___.txt
@@ -23,4 +23,10 @@ Options:
[required]
--base-vws-url TEXT The base URL for the VWS API. [default:
https://vws.vuforia.com]
+ --connection-timeout-seconds FLOAT RANGE
+ The connection timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
+ --read-timeout-seconds FLOAT RANGE
+ The read timeout for HTTP requests, in
+ seconds. [default: 30; x>=0.05]
-h, --help Show this message and exit.
diff --git a/tests/test_model_target.py b/tests/test_model_target.py
new file mode 100644
index 00000000..006a8803
--- /dev/null
+++ b/tests/test_model_target.py
@@ -0,0 +1,986 @@
+"""Tests for the Model Target dataset commands."""
+
+import json
+import uuid
+import zipfile
+from collections.abc import Iterator
+from pathlib import Path
+from typing import Any
+
+import pytest
+from click.testing import CliRunner
+from mock_vws import (
+ MockVWS,
+ ModelTargetGenerationFailure,
+ ModelTargetGenerationWarning,
+)
+from vws.exceptions.custom_exceptions import ServerError
+from vws.exceptions.model_target_exceptions import (
+ ModelTargetAuthenticationError,
+ ModelTargetError,
+ ModelTargetValidationError,
+)
+from vws.response import Response
+
+from vws_cli import vws_group
+from vws_cli._error_handling import get_model_target_error_message
+
+# The credentials which ``vws-python-mock`` accepts for the Model Target
+# Web API.
+_CLIENT_ID = "client-id"
+_CLIENT_SECRET = "client-secret" # noqa: S105
+
+_CREDENTIAL_ARGS = [
+ "--client-id",
+ _CLIENT_ID,
+ "--client-secret",
+ _CLIENT_SECRET,
+]
+
+_CAD_DATA_URL = "https://example.com/model.zip"
+
+# The exit code which ``click`` uses for a usage error.
+_USAGE_ERROR_EXIT_CODE = 2
+
+
+@pytest.fixture(name="model_target_mock")
+def fixture_model_target_mock() -> Iterator[MockVWS]:
+ """Yield a mock which generates Model Target datasets immediately."""
+ with MockVWS(processing_time_seconds=0) as mock:
+ yield mock
+
+
+def _create_dataset(
+ *,
+ runner: CliRunner,
+ extra_args: list[str],
+) -> str:
+ """Create a dataset from one model, and return the dataset UUID."""
+ args = [
+ "create-model-target-dataset",
+ "--name",
+ "my-dataset",
+ "--target-sdk",
+ "10.29",
+ "--model-name",
+ "my-model",
+ "--cad-data-url",
+ _CAD_DATA_URL,
+ *_CREDENTIAL_ARGS,
+ *extra_args,
+ ]
+ result = runner.invoke(
+ cli=vws_group,
+ args=args,
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 0, result.output
+ return result.stdout.strip()
+
+
+def _response(*, status_code: int, text: str) -> Response:
+ """Return a response, as ``VWS-Python`` gives to an exception."""
+ return Response(
+ text=text,
+ url="https://vws.vuforia.com/modeltargets/datasets",
+ status_code=status_code,
+ headers={},
+ request_body=None,
+ tell_position=0,
+ content=text.encode(encoding="utf-8"),
+ )
+
+
+@pytest.mark.parametrize(
+ argnames="dataset_type", argvalues=["standard", "advanced"]
+)
+@pytest.mark.usefixtures("model_target_mock")
+def test_dataset_lifecycle(*, dataset_type: str, tmp_path: Path) -> None:
+ """A dataset can be created, polled, downloaded and deleted."""
+ runner = CliRunner()
+ type_args = ["--dataset-type", dataset_type]
+ dataset_uuid = _create_dataset(runner=runner, extra_args=type_args)
+ dataset_args = [
+ "--dataset-uuid",
+ dataset_uuid,
+ *type_args,
+ *_CREDENTIAL_ARGS,
+ ]
+
+ status_result = runner.invoke(
+ cli=vws_group,
+ args=["get-model-target-dataset-status", *dataset_args],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert status_result.exit_code == 0
+ assert f"dataset_uuid: {dataset_uuid}" in status_result.stdout
+ assert "status: done" in status_result.stdout
+
+ wait_result = runner.invoke(
+ cli=vws_group,
+ args=["wait-for-model-target-dataset-generated", *dataset_args],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert wait_result.exit_code == 0
+ assert not wait_result.stderr
+ assert "status: done" in wait_result.stdout
+
+ output_file_path = tmp_path / "dataset.zip"
+ download_result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "download-model-target-dataset",
+ "--output",
+ str(object=output_file_path),
+ *dataset_args,
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert download_result.exit_code == 0
+ assert not download_result.stdout
+ assert zipfile.is_zipfile(filename=output_file_path)
+
+ delete_result = runner.invoke(
+ cli=vws_group,
+ args=["delete-model-target-dataset", *dataset_args],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert delete_result.exit_code == 0
+ assert not delete_result.stdout
+
+ deleted_status_result = runner.invoke(
+ cli=vws_group,
+ args=["get-model-target-dataset-status", *dataset_args],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert deleted_status_result.exit_code == 1
+ assert deleted_status_result.stderr == (
+ "Error: No Model Target dataset of the given type matches the given "
+ "UUID.\n"
+ )
+
+
+@pytest.mark.usefixtures("model_target_mock")
+def test_dataset_types_are_separate() -> None:
+ """A standard dataset is not visible to advanced dataset requests."""
+ runner = CliRunner()
+ dataset_uuid = _create_dataset(runner=runner, extra_args=[])
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "get-model-target-dataset-status",
+ "--dataset-uuid",
+ dataset_uuid,
+ "--dataset-type",
+ "advanced",
+ *_CREDENTIAL_ARGS,
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 1
+ assert result.stderr == (
+ "Error: No Model Target dataset of the given type matches the given "
+ "UUID.\n"
+ )
+
+
+@pytest.mark.usefixtures("model_target_mock")
+def test_cad_data_file(*, tmp_path: Path) -> None:
+ """A model's CAD data can be given as a file."""
+ runner = CliRunner()
+ cad_data_file_path = tmp_path / "model.obj"
+ cad_data_file_path.write_bytes(data=b"\x00cad-data")
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "create-model-target-dataset",
+ "--name",
+ "my-dataset",
+ "--target-sdk",
+ "10.29",
+ "--model-name",
+ "my-model",
+ "--cad-data-file",
+ str(object=cad_data_file_path),
+ *_CREDENTIAL_ARGS,
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 0
+ assert result.stdout.strip()
+
+
+@pytest.mark.usefixtures("model_target_mock")
+def test_model_options(*, tmp_path: Path) -> None:
+ """The optional model settings are sent to Vuforia."""
+ runner = CliRunner()
+ state_based_configuration_file_path = tmp_path / "states.json"
+ state_based_configuration_file_path.write_text(
+ data=json.dumps(obj={"states": {"open": {}}}),
+ )
+ dataset_uuid = _create_dataset(
+ runner=runner,
+ extra_args=[
+ "--dataset-type",
+ "advanced",
+ "--automatic-coloring",
+ "always",
+ "--cad-data-format",
+ "OBJ",
+ "--motion-hint",
+ "static",
+ "--optimize-tracking-for",
+ "default",
+ "--realistic-appearance",
+ "auto",
+ "--simplify",
+ "never",
+ "--tracking-mode",
+ "car",
+ "--state-based-configuration-file",
+ str(object=state_based_configuration_file_path),
+ ],
+ )
+ assert dataset_uuid
+
+
+def _models_file(*, tmp_path: Path, models_json: object) -> Path:
+ """Write a models file, and return its path."""
+ models_file_path = tmp_path / "models.json"
+ models_file_path.write_text(data=json.dumps(obj=models_json))
+ return models_file_path
+
+
+@pytest.mark.usefixtures("model_target_mock")
+def test_models_file(*, tmp_path: Path) -> None:
+ """Multiple models, with guide views, can be given in a file."""
+ runner = CliRunner()
+ models_file_path = _models_file(
+ tmp_path=tmp_path,
+ models_json=[
+ {
+ "name": "first-model",
+ "cadDataUrl": _CAD_DATA_URL,
+ "automaticColoring": "auto",
+ "cadDataFormat": "OBJ",
+ "motionHint": "static",
+ "optimizeTrackingFor": "default",
+ "realisticAppearance": "true",
+ "simplify": "auto",
+ "trackingMode": "default",
+ "stateBasedConfigurationJsonString": (
+ '{"states": {"open": {}}}'
+ ),
+ "views": [
+ {
+ "name": "front",
+ "guideViewPosition": {
+ "rotation": [0, 0, 0, 1],
+ "translation": [0.0, 0.0, -1.5],
+ },
+ "states": ["open"],
+ },
+ ],
+ },
+ {
+ "name": "second-model",
+ "cadDataBlob": "Y2FkLWRhdGE=",
+ },
+ ],
+ )
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "create-model-target-dataset",
+ "--name",
+ "my-dataset",
+ "--target-sdk",
+ "10.29",
+ "--dataset-type",
+ "advanced",
+ "--models-file",
+ str(object=models_file_path),
+ *_CREDENTIAL_ARGS,
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 0
+ assert result.stdout.strip()
+
+
+@pytest.mark.usefixtures("model_target_mock")
+def test_models_file_with_models_key(*, tmp_path: Path) -> None:
+ """A models file may hold an object with a ``models`` array."""
+ runner = CliRunner()
+ models_file_path = _models_file(
+ tmp_path=tmp_path,
+ models_json={
+ "models": [{"name": "my-model", "cadDataUrl": _CAD_DATA_URL}],
+ },
+ )
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "create-model-target-dataset",
+ "--name",
+ "my-dataset",
+ "--target-sdk",
+ "10.29",
+ "--models-file",
+ str(object=models_file_path),
+ *_CREDENTIAL_ARGS,
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 0
+ assert result.stdout.strip()
+
+
+@pytest.mark.usefixtures("model_target_mock")
+def test_models_file_with_model_options(*, tmp_path: Path) -> None:
+ """``--models-file`` cannot be used with the single-model options."""
+ runner = CliRunner()
+ models_file_path = _models_file(
+ tmp_path=tmp_path,
+ models_json=[{"name": "my-model", "cadDataUrl": _CAD_DATA_URL}],
+ )
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "create-model-target-dataset",
+ "--name",
+ "my-dataset",
+ "--target-sdk",
+ "10.29",
+ "--model-name",
+ "my-model",
+ "--simplify",
+ "never",
+ "--models-file",
+ str(object=models_file_path),
+ *_CREDENTIAL_ARGS,
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == _USAGE_ERROR_EXIT_CODE
+ assert (
+ "Error: --models-file cannot be used with --model-name, --simplify."
+ in result.stderr
+ )
+
+
+@pytest.mark.usefixtures("model_target_mock")
+def test_model_name_required() -> None:
+ """A model name is required when no models file is given."""
+ runner = CliRunner()
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "create-model-target-dataset",
+ "--name",
+ "my-dataset",
+ "--target-sdk",
+ "10.29",
+ "--cad-data-url",
+ _CAD_DATA_URL,
+ *_CREDENTIAL_ARGS,
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == _USAGE_ERROR_EXIT_CODE
+ assert (
+ "Error: --model-name is required when --models-file is not."
+ in result.stderr
+ )
+
+
+@pytest.mark.parametrize(
+ argnames="cad_data_args",
+ argvalues=[
+ pytest.param([], id="neither"),
+ pytest.param(
+ ["--cad-data-url", _CAD_DATA_URL, "--cad-data-file", __file__],
+ id="both",
+ ),
+ ],
+)
+@pytest.mark.usefixtures("model_target_mock")
+def test_one_cad_data_source_required(*, cad_data_args: list[str]) -> None:
+ """Exactly one CAD data source is required for a single model."""
+ runner = CliRunner()
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "create-model-target-dataset",
+ "--name",
+ "my-dataset",
+ "--target-sdk",
+ "10.29",
+ "--model-name",
+ "my-model",
+ *cad_data_args,
+ *_CREDENTIAL_ARGS,
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == _USAGE_ERROR_EXIT_CODE
+ assert (
+ "Error: Exactly one of --cad-data-url and --cad-data-file is required."
+ in result.stderr
+ )
+
+
+_VALID_POSITION: dict[str, Any] = {
+ "rotation": [0, 0, 0, 1],
+ "translation": [0, 0, -1],
+}
+
+_VALID_VIEW: dict[str, Any] = {
+ "name": "front",
+ "guideViewPosition": _VALID_POSITION,
+}
+
+_EMPTY_OBJECT: dict[str, Any] = {}
+
+_EMPTY_ARRAY: list[Any] = []
+
+
+@pytest.mark.parametrize(
+ argnames=("models_json", "expected_message"),
+ argvalues=[
+ pytest.param(
+ {"models": "my-model"},
+ "/models must be an array.",
+ id="models-not-an-array",
+ ),
+ pytest.param(
+ ["my-model"],
+ "/models(0) must be an object.",
+ id="model-not-an-object",
+ ),
+ pytest.param(
+ [{"cadDataUrl": _CAD_DATA_URL}],
+ "/models(0)/name is required.",
+ id="model-without-name",
+ ),
+ pytest.param(
+ [{"name": "my-model", "cadData": _CAD_DATA_URL}],
+ "/models(0) has unknown fields: cadData.",
+ id="unknown-model-field",
+ ),
+ pytest.param(
+ [{"name": 1}],
+ "/models(0)/name must be a string.",
+ id="model-name-not-a-string",
+ ),
+ pytest.param(
+ [{"name": "my-model", "simplify": "sometimes"}],
+ "/models(0)/simplify must be one of: always, auto, never.",
+ id="invalid-enum-value",
+ ),
+ pytest.param(
+ [{"name": "my-model", "views": _EMPTY_OBJECT}],
+ "/models(0)/views must be an array.",
+ id="views-not-an-array",
+ ),
+ pytest.param(
+ [{"name": "my-model", "views": ["front"]}],
+ "/models(0)/views(0) must be an object.",
+ id="view-not-an-object",
+ ),
+ pytest.param(
+ [{"name": "my-model", "views": [{**_VALID_VIEW, "extra": 1}]}],
+ "/models(0)/views(0) has unknown fields: extra.",
+ id="unknown-view-field",
+ ),
+ pytest.param(
+ [{"name": "my-model", "views": [{"name": "front"}]}],
+ "/models(0)/views(0)/guideViewPosition is required.",
+ id="view-without-position",
+ ),
+ pytest.param(
+ [
+ {
+ "name": "my-model",
+ "views": [
+ {**_VALID_VIEW, "guideViewPosition": _EMPTY_ARRAY},
+ ],
+ },
+ ],
+ "/models(0)/views(0)/guideViewPosition must be an object.",
+ id="position-not-an-object",
+ ),
+ pytest.param(
+ [
+ {
+ "name": "my-model",
+ "views": [
+ {
+ **_VALID_VIEW,
+ "guideViewPosition": {
+ "rotation": [0, 0, 0, 1],
+ },
+ },
+ ],
+ },
+ ],
+ "/models(0)/views(0)/guideViewPosition/translation is required.",
+ id="position-without-translation",
+ ),
+ pytest.param(
+ [
+ {
+ "name": "my-model",
+ "views": [
+ {
+ **_VALID_VIEW,
+ "guideViewPosition": {
+ "rotation": [0, 0, 0, 1],
+ "translation": [0, 0, -1],
+ "scale": 1,
+ },
+ },
+ ],
+ },
+ ],
+ "/models(0)/views(0)/guideViewPosition has unknown fields: scale.",
+ id="unknown-position-field",
+ ),
+ pytest.param(
+ [
+ {
+ "name": "my-model",
+ "views": [
+ {
+ **_VALID_VIEW,
+ "guideViewPosition": {
+ "rotation": "0 0 0 1",
+ "translation": [0, 0, -1],
+ },
+ },
+ ],
+ },
+ ],
+ (
+ "/models(0)/views(0)/guideViewPosition/rotation must be an "
+ "array of numbers."
+ ),
+ id="rotation-not-an-array",
+ ),
+ pytest.param(
+ [
+ {
+ "name": "my-model",
+ "views": [
+ {
+ **_VALID_VIEW,
+ "guideViewPosition": {
+ "rotation": [0, 0, 0, True],
+ "translation": [0, 0, -1],
+ },
+ },
+ ],
+ },
+ ],
+ (
+ "/models(0)/views(0)/guideViewPosition/rotation must be an "
+ "array of numbers."
+ ),
+ id="rotation-with-a-boolean",
+ ),
+ pytest.param(
+ [
+ {
+ "name": "my-model",
+ "views": [{**_VALID_VIEW, "states": _EMPTY_OBJECT}],
+ },
+ ],
+ "/models(0)/views(0)/states must be an array of strings.",
+ id="states-not-an-array",
+ ),
+ pytest.param(
+ [{"name": "my-model", "views": [{**_VALID_VIEW, "states": [1]}]}],
+ "/models(0)/views(0)/states(0) must be a string.",
+ id="state-not-a-string",
+ ),
+ ],
+)
+@pytest.mark.usefixtures("model_target_mock")
+def test_invalid_models_file(
+ *,
+ models_json: object,
+ expected_message: str,
+ tmp_path: Path,
+) -> None:
+ """An error is shown for a models file which cannot be used."""
+ runner = CliRunner()
+ models_file_path = _models_file(
+ tmp_path=tmp_path,
+ models_json=models_json,
+ )
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "create-model-target-dataset",
+ "--name",
+ "my-dataset",
+ "--target-sdk",
+ "10.29",
+ "--models-file",
+ str(object=models_file_path),
+ *_CREDENTIAL_ARGS,
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == _USAGE_ERROR_EXIT_CODE
+ expected_error = (
+ f"Error: Invalid value for '--models-file': {expected_message}"
+ )
+ assert expected_error in result.stderr
+
+
+@pytest.mark.usefixtures("model_target_mock")
+def test_models_file_without_models_key(*, tmp_path: Path) -> None:
+ """An object models file must have a ``models`` array."""
+ runner = CliRunner()
+ models_file_path = _models_file(
+ tmp_path=tmp_path,
+ models_json={"name": "my-dataset"},
+ )
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "create-model-target-dataset",
+ "--name",
+ "my-dataset",
+ "--target-sdk",
+ "10.29",
+ "--models-file",
+ str(object=models_file_path),
+ *_CREDENTIAL_ARGS,
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == _USAGE_ERROR_EXIT_CODE
+ assert (
+ "Error: Invalid value for '--models-file': /models is required."
+ in result.stderr
+ )
+
+
+@pytest.mark.usefixtures("model_target_mock")
+def test_models_file_is_not_json(*, tmp_path: Path) -> None:
+ """An error is shown for a models file which is not JSON."""
+ runner = CliRunner()
+ models_file_path = tmp_path / "models.json"
+ models_file_path.write_text(data="not-json")
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "create-model-target-dataset",
+ "--name",
+ "my-dataset",
+ "--target-sdk",
+ "10.29",
+ "--models-file",
+ str(object=models_file_path),
+ *_CREDENTIAL_ARGS,
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == _USAGE_ERROR_EXIT_CODE
+ expected_error = (
+ f"Error: Invalid value for '--models-file': {models_file_path} is "
+ "not valid JSON."
+ )
+ assert expected_error in result.stderr
+
+
+@pytest.mark.usefixtures("model_target_mock")
+def test_vuforia_validation_error(*, tmp_path: Path) -> None:
+ """The reasons which Vuforia gives for rejecting a request are
+ shown.
+ """
+ runner = CliRunner()
+ models_file_path = _models_file(
+ tmp_path=tmp_path,
+ models_json=[
+ {"name": "first-model", "cadDataUrl": _CAD_DATA_URL},
+ {"name": "second-model", "cadDataUrl": _CAD_DATA_URL},
+ ],
+ )
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "create-model-target-dataset",
+ "--name",
+ "my-dataset",
+ "--target-sdk",
+ "10.29",
+ "--models-file",
+ str(object=models_file_path),
+ *_CREDENTIAL_ARGS,
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 1
+ assert result.stderr == (
+ "Error: Vuforia rejected the request.\n"
+ "VALIDATION_ERROR: exactly one model should be provided\n"
+ )
+
+
+@pytest.mark.usefixtures("model_target_mock")
+def test_authentication_failure() -> None:
+ """An error is shown when the given credentials are not accepted."""
+ runner = CliRunner()
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "create-model-target-dataset",
+ "--name",
+ "my-dataset",
+ "--target-sdk",
+ "10.29",
+ "--model-name",
+ "my-model",
+ "--cad-data-url",
+ _CAD_DATA_URL,
+ "--client-id",
+ _CLIENT_ID,
+ "--client-secret",
+ "wrong-client-secret",
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 1
+ assert result.stderr == (
+ "Error: The given client ID and client secret are not a set of Model "
+ "Target Web API credentials.\n"
+ )
+
+
+def test_status_while_processing() -> None:
+ """The status of a dataset which is being generated is shown."""
+ runner = CliRunner()
+ with MockVWS(processing_time_seconds=9999):
+ dataset_uuid = _create_dataset(runner=runner, extra_args=[])
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "get-model-target-dataset-status",
+ "--dataset-uuid",
+ dataset_uuid,
+ *_CREDENTIAL_ARGS,
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+
+ assert result.exit_code == 0
+ assert "status: processing" in result.stdout
+ assert "completed_at: null" in result.stdout
+
+
+def test_download_before_generated(*, tmp_path: Path) -> None:
+ """An error is shown when a dataset is not ready to download."""
+ runner = CliRunner()
+ output_file_path = tmp_path / "dataset.zip"
+ with MockVWS(processing_time_seconds=9999):
+ dataset_uuid = _create_dataset(runner=runner, extra_args=[])
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "download-model-target-dataset",
+ "--dataset-uuid",
+ dataset_uuid,
+ "--output",
+ str(object=output_file_path),
+ *_CREDENTIAL_ARGS,
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+
+ assert result.exit_code == 1
+ assert result.stderr == (
+ "Error: Vuforia has not finished generating the dataset, so the "
+ "dataset cannot be downloaded.\n"
+ )
+ assert not output_file_path.exists()
+
+
+def test_wait_timeout() -> None:
+ """An error is shown when waiting for a dataset times out."""
+ runner = CliRunner()
+ with MockVWS(processing_time_seconds=9999):
+ dataset_uuid = _create_dataset(runner=runner, extra_args=[])
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "wait-for-model-target-dataset-generated",
+ "--dataset-uuid",
+ dataset_uuid,
+ "--seconds-between-requests",
+ "0.05",
+ "--timeout-seconds",
+ "0.05",
+ *_CREDENTIAL_ARGS,
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+
+ assert result.exit_code == 1
+ assert result.stderr == "Timeout of 0.05 seconds reached.\n"
+
+
+def test_wait_for_failed_dataset() -> None:
+ """A dataset which failed to generate gives a non-zero exit code."""
+ runner = CliRunner()
+ failure = ModelTargetGenerationFailure(message="Generation failed")
+ with MockVWS(
+ processing_time_seconds=0,
+ model_target_generation_failure=failure,
+ ):
+ dataset_uuid = _create_dataset(runner=runner, extra_args=[])
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "wait-for-model-target-dataset-generated",
+ "--dataset-uuid",
+ dataset_uuid,
+ *_CREDENTIAL_ARGS,
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+
+ assert result.exit_code == 1
+ assert "status: failed" in result.stdout
+ assert "message: Generation failed" in result.stdout
+ assert result.stderr == "Error: Vuforia failed to generate the dataset.\n"
+
+
+def test_wait_for_dataset_with_warning() -> None:
+ """A warning about a generated dataset is shown."""
+ runner = CliRunner()
+ warning = ModelTargetGenerationWarning()
+ with MockVWS(
+ processing_time_seconds=0,
+ model_target_generation_warning=warning,
+ ):
+ dataset_uuid = _create_dataset(runner=runner, extra_args=[])
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "wait-for-model-target-dataset-generated",
+ "--dataset-uuid",
+ dataset_uuid,
+ *_CREDENTIAL_ARGS,
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+
+ assert result.exit_code == 0
+ assert not result.stderr
+ assert "status: done" in result.stdout
+ assert f"message: {warning.message}" in result.stdout
+
+
+def test_authentication_error_message() -> None:
+ """A rejected authenticated request gives a useful message."""
+ exc = ModelTargetAuthenticationError(
+ response=_response(status_code=401, text="Unauthorized"),
+ )
+ assert (
+ get_model_target_error_message(exc=exc)
+ == "Error: The request to Vuforia was not authenticated."
+ )
+
+
+def test_validation_error_message_without_details() -> None:
+ """A validation error without details gives the Vuforia message."""
+ body = json.dumps(
+ obj={"error": {"code": "VALIDATION_ERROR", "message": "Bad request"}},
+ )
+ exc = ModelTargetValidationError(
+ response=_response(status_code=400, text=body),
+ )
+ assert get_model_target_error_message(exc=exc) == (
+ "Error: Vuforia rejected the request.\nBad request"
+ )
+
+
+@pytest.mark.parametrize(
+ argnames=("text", "expected_message"),
+ argvalues=[
+ pytest.param(
+ json.dumps(obj={"error": {"code": "X", "message": "Bad request"}}),
+ "Error: Bad request",
+ id="with-message",
+ ),
+ pytest.param(
+ "Bad Request",
+ "Error: Vuforia returned an error.",
+ id="without-message",
+ ),
+ ],
+)
+def test_other_error_message(*, text: str, expected_message: str) -> None:
+ """Another error from Vuforia gives a useful message."""
+ exc = ModelTargetError(response=_response(status_code=403, text=text))
+ assert get_model_target_error_message(exc=exc) == expected_message
+
+
+def test_server_error_message() -> None:
+ """An error from the Vuforia servers gives a useful message."""
+ exc = ServerError(
+ response=_response(status_code=500, text="Internal Server Error"),
+ )
+ assert get_model_target_error_message(exc=exc) == (
+ "Error: There was an unknown error from Vuforia. This may be because "
+ "there is a problem with the given name."
+ )
+
+
+def test_unknown_dataset_uuid() -> None:
+ """An error is shown for a UUID which does not match a dataset."""
+ runner = CliRunner()
+ with MockVWS():
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ "delete-model-target-dataset",
+ "--dataset-uuid",
+ uuid.uuid4().hex,
+ *_CREDENTIAL_ARGS,
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+
+ assert result.exit_code == 1
+ assert result.stderr == (
+ "Error: No Model Target dataset of the given type matches the given "
+ "UUID.\n"
+ )
diff --git a/tests/test_query.py b/tests/test_query.py
index 1c229a7a..5fe1ea1d 100644
--- a/tests/test_query.py
+++ b/tests/test_query.py
@@ -1,36 +1,35 @@
-"""
-Test for the Cloud Reco Service commands.
-"""
+"""Test for the Cloud Reco Service commands."""
+import datetime
import io
import uuid
from pathlib import Path
from textwrap import dedent
+import pytest
+import requests
import yaml
from click.testing import CliRunner
+from freezegun import freeze_time
from mock_vws import MockVWS
-from mock_vws.database import VuforiaDatabase
+from mock_vws.database import CloudDatabase
from vws import VWS
from vws_cli.query import vuforia_cloud_reco
class TestQuery:
- """
- Tests for making image queries.
- """
+ """Tests for making image queries."""
@staticmethod
def test_no_matches(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
tmp_path: Path,
high_quality_image: io.BytesIO,
) -> None:
- """
- An empty list is returned if there are no matches.
- """
- runner = CliRunner(mix_stderr=False)
+ """An empty list is returned if there are no matches."""
+ runner = CliRunner()
new_file = tmp_path / uuid.uuid4().hex
image_data = high_quality_image.getvalue()
new_file.write_bytes(data=image_data)
@@ -53,14 +52,13 @@ def test_no_matches(
@staticmethod
def test_matches(
+ *,
tmp_path: Path,
high_quality_image: io.BytesIO,
vws_client: VWS,
- mock_database: VuforiaDatabase,
+ mock_database: CloudDatabase,
) -> None:
- """
- Details of matching targets are shown.
- """
+ """Details of matching targets are shown."""
name = uuid.uuid4().hex
target_id = vws_client.add_target(
name=name,
@@ -71,7 +69,7 @@ def test_matches(
)
vws_client.wait_for_target_processed(target_id=target_id)
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
new_file = tmp_path / uuid.uuid4().hex
image_data = high_quality_image.getvalue()
new_file.write_bytes(data=image_data)
@@ -104,14 +102,16 @@ def test_matches(
@staticmethod
def test_image_file_is_dir(
+ *,
tmp_path: Path,
- mock_database: VuforiaDatabase,
+ mock_database: CloudDatabase,
) -> None:
"""
- An appropriate error is given if the given image file path points to a
+ An appropriate error is given if the given image file path
+ points to a
directory.
"""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
commands = [
str(object=tmp_path),
"--client-access-key",
@@ -140,14 +140,13 @@ def test_image_file_is_dir(
@staticmethod
def test_relative_path(
+ *,
tmp_path: Path,
- mock_database: VuforiaDatabase,
+ mock_database: CloudDatabase,
high_quality_image: io.BytesIO,
) -> None:
- """
- Image file paths are resolved.
- """
- runner = CliRunner(mix_stderr=False)
+ """Image file paths are resolved."""
+ runner = CliRunner()
new_filename = uuid.uuid4().hex
original_image_file = tmp_path / "foo"
image_data = high_quality_image.getvalue()
@@ -175,13 +174,15 @@ def test_relative_path(
@staticmethod
def test_image_file_does_not_exist(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
tmp_path: Path,
) -> None:
"""
- An appropriate error is given if the given image file does not exist.
+ An appropriate error is given if the given image file does not
+ exist.
"""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
does_not_exist_file = tmp_path / uuid.uuid4().hex
commands = [
str(object=does_not_exist_file),
@@ -210,11 +211,165 @@ def test_image_file_does_not_exist(
assert result.stderr == expected_stderr
+class TestDefaultRequestTimeout:
+ """Tests for the default request timeout."""
+
+ @staticmethod
+ @pytest.mark.parametrize(
+ argnames=("response_delay_seconds", "expect_timeout"),
+ argvalues=[(29, False), (31, True)],
+ )
+ def test_default_timeout(
+ *,
+ high_quality_image: io.BytesIO,
+ tmp_path: Path,
+ response_delay_seconds: int,
+ expect_timeout: bool,
+ ) -> None:
+ """At 29 seconds there is no error; at 31 seconds there is a
+ timeout.
+ """
+ runner = CliRunner()
+ new_file = tmp_path / uuid.uuid4().hex
+ image_data = high_quality_image.getvalue()
+ new_file.write_bytes(data=image_data)
+ with (
+ freeze_time() as frozen_datetime,
+ MockVWS(
+ response_delay_seconds=response_delay_seconds,
+ sleep_fn=lambda seconds: (
+ frozen_datetime.tick(
+ delta=datetime.timedelta(seconds=seconds),
+ ),
+ None,
+ )[1],
+ ) as mock,
+ ):
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
+ commands = [
+ str(object=new_file),
+ "--client-access-key",
+ database.client_access_key,
+ "--client-secret-key",
+ database.client_secret_key,
+ ]
+
+ if expect_timeout:
+ with pytest.raises(
+ expected_exception=requests.exceptions.Timeout,
+ ):
+ runner.invoke(
+ cli=vuforia_cloud_reco,
+ args=commands,
+ catch_exceptions=False,
+ color=True,
+ )
+ else:
+ result = runner.invoke(
+ cli=vuforia_cloud_reco,
+ args=commands,
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 0
+
+
+class TestCustomRequestTimeout:
+ """Tests for custom request timeout options."""
+
+ @staticmethod
+ def test_custom_timeout(
+ *,
+ high_quality_image: io.BytesIO,
+ tmp_path: Path,
+ ) -> None:
+ """Custom connection and read timeouts are respected."""
+ runner = CliRunner()
+ new_file = tmp_path / uuid.uuid4().hex
+ image_data = high_quality_image.getvalue()
+ new_file.write_bytes(data=image_data)
+ with (
+ freeze_time() as frozen_datetime,
+ MockVWS(
+ response_delay_seconds=5,
+ sleep_fn=lambda seconds: (
+ frozen_datetime.tick(
+ delta=datetime.timedelta(seconds=seconds),
+ ),
+ None,
+ )[1],
+ ) as mock,
+ ):
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
+ commands = [
+ str(object=new_file),
+ "--client-access-key",
+ database.client_access_key,
+ "--client-secret-key",
+ database.client_secret_key,
+ "--read-timeout-seconds",
+ "1",
+ ]
+
+ with pytest.raises(
+ expected_exception=requests.exceptions.Timeout,
+ ):
+ runner.invoke(
+ cli=vuforia_cloud_reco,
+ args=commands,
+ catch_exceptions=False,
+ color=True,
+ )
+
+ @staticmethod
+ def test_custom_timeout_no_error(
+ *,
+ high_quality_image: io.BytesIO,
+ tmp_path: Path,
+ ) -> None:
+ """A sufficiently large timeout does not cause an error."""
+ runner = CliRunner()
+ new_file = tmp_path / uuid.uuid4().hex
+ image_data = high_quality_image.getvalue()
+ new_file.write_bytes(data=image_data)
+ with (
+ freeze_time() as frozen_datetime,
+ MockVWS(
+ response_delay_seconds=5,
+ sleep_fn=lambda seconds: (
+ frozen_datetime.tick(
+ delta=datetime.timedelta(seconds=seconds),
+ ),
+ None,
+ )[1],
+ ) as mock,
+ ):
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
+ commands = [
+ str(object=new_file),
+ "--client-access-key",
+ database.client_access_key,
+ "--client-secret-key",
+ database.client_secret_key,
+ "--read-timeout-seconds",
+ "60",
+ ]
+
+ result = runner.invoke(
+ cli=vuforia_cloud_reco,
+ args=commands,
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 0
+
+
def test_version() -> None:
- """
- ``vuforia-cloud-reco --version`` shows the version.
- """
- runner = CliRunner(mix_stderr=False)
+ """``vuforia-cloud-reco --version`` shows the version."""
+ runner = CliRunner()
commands = ["--version"]
result = runner.invoke(
cli=vuforia_cloud_reco,
@@ -227,21 +382,18 @@ def test_version() -> None:
class TestMaxNumResults:
- """
- Tests for the ``--max-num-results`` option.
- """
+ """Tests for the ``--max-num-results`` option."""
@staticmethod
def test_default(
+ *,
vws_client: VWS,
high_quality_image: io.BytesIO,
tmp_path: Path,
- mock_database: VuforiaDatabase,
+ mock_database: CloudDatabase,
) -> None:
- """
- By default the maximum number of results is 1.
- """
- runner = CliRunner(mix_stderr=False)
+ """By default the maximum number of results is 1."""
+ runner = CliRunner()
target_id = vws_client.add_target(
name=uuid.uuid4().hex,
width=1,
@@ -281,15 +433,14 @@ def test_default(
@staticmethod
def test_custom(
+ *,
vws_client: VWS,
high_quality_image: io.BytesIO,
tmp_path: Path,
- mock_database: VuforiaDatabase,
+ mock_database: CloudDatabase,
) -> None:
- """
- It is possible to set a custom ``--max-num-results``.
- """
- runner = CliRunner(mix_stderr=False)
+ """It is possible to set a custom ``--max-num-results``."""
+ runner = CliRunner()
target_id = vws_client.add_target(
name=uuid.uuid4().hex,
width=1,
@@ -340,12 +491,13 @@ def test_custom(
@staticmethod
def test_out_of_range(
+ *,
high_quality_image: io.BytesIO,
tmp_path: Path,
- mock_database: VuforiaDatabase,
+ mock_database: CloudDatabase,
) -> None:
"""``--max-num-results`` must be between 1 and 50."""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
new_file = tmp_path / uuid.uuid4().hex
image_data = high_quality_image.getvalue()
new_file.write_bytes(data=image_data)
@@ -374,21 +526,18 @@ def test_out_of_range(
class TestIncludeTargetData:
- """
- Tests for the ``--include-target-data`` option.
- """
+ """Tests for the ``--include-target-data`` option."""
@staticmethod
def test_default(
+ *,
vws_client: VWS,
high_quality_image: io.BytesIO,
tmp_path: Path,
- mock_database: VuforiaDatabase,
+ mock_database: CloudDatabase,
) -> None:
- """
- By default, target data is only returned in the top match.
- """
- runner = CliRunner(mix_stderr=False)
+ """By default, target data is only returned in the top match."""
+ runner = CliRunner()
target_id = vws_client.add_target(
name=uuid.uuid4().hex,
width=1,
@@ -431,15 +580,17 @@ def test_default(
@staticmethod
def test_top(
+ *,
vws_client: VWS,
high_quality_image: io.BytesIO,
tmp_path: Path,
- mock_database: VuforiaDatabase,
+ mock_database: CloudDatabase,
) -> None:
"""
- When 'top' is given, target data is only returned in the top match.
+ When 'top' is given, target data is only returned in the top
+ match.
"""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
target_id = vws_client.add_target(
name=uuid.uuid4().hex,
width=1,
@@ -484,15 +635,17 @@ def test_top(
@staticmethod
def test_none(
+ *,
vws_client: VWS,
high_quality_image: io.BytesIO,
tmp_path: Path,
- mock_database: VuforiaDatabase,
+ mock_database: CloudDatabase,
) -> None:
"""
- When 'none' is given, target data is not returned in any match.
+ When 'none' is given, target data is not returned in any
+ match.
"""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
target_id = vws_client.add_target(
name=uuid.uuid4().hex,
width=1,
@@ -537,15 +690,14 @@ def test_none(
@staticmethod
def test_all(
+ *,
vws_client: VWS,
high_quality_image: io.BytesIO,
tmp_path: Path,
- mock_database: VuforiaDatabase,
+ mock_database: CloudDatabase,
) -> None:
- """
- When 'all' is given, target data is returned in all matches.
- """
- runner = CliRunner(mix_stderr=False)
+ """When 'all' is given, target data is returned in all matches."""
+ runner = CliRunner()
target_id = vws_client.add_target(
name=uuid.uuid4().hex,
width=1,
@@ -591,15 +743,17 @@ def test_all(
@staticmethod
def test_other(
+ *,
high_quality_image: io.BytesIO,
tmp_path: Path,
- mock_database: VuforiaDatabase,
+ mock_database: CloudDatabase,
) -> None:
"""
- When a string other than 'top', 'all', or 'none' is given, an error is
+ When a string other than 'top', 'all', or 'none' is given, an
+ error is
shown.
"""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
new_file = tmp_path / uuid.uuid4().hex
image_data = high_quality_image.getvalue()
new_file.write_bytes(data=image_data)
@@ -629,18 +783,21 @@ def test_other(
assert expected_stderr in result.stderr
-def test_base_vwq_url(high_quality_image: io.BytesIO, tmp_path: Path) -> None:
+def test_base_vwq_url(
+ *, high_quality_image: io.BytesIO, tmp_path: Path
+) -> None:
"""
- It is possible to use query a target to a database under a custom VWQ URL.
+ It is possible to use query a target to a database under a custom
+ VWQ URL.
"""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
base_vwq_url = "http://example.com"
new_file = tmp_path / uuid.uuid4().hex
image_data = high_quality_image.getvalue()
new_file.write_bytes(data=image_data)
with MockVWS(base_vwq_url=base_vwq_url) as mock:
- mock_database = VuforiaDatabase()
- mock.add_database(database=mock_database)
+ mock_database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=mock_database)
vws_client = VWS(
server_access_key=mock_database.server_access_key,
server_secret_key=mock_database.server_secret_key,
@@ -678,14 +835,16 @@ def test_base_vwq_url(high_quality_image: io.BytesIO, tmp_path: Path) -> None:
def test_env_var_credentials(
+ *,
high_quality_image: io.BytesIO,
tmp_path: Path,
- mock_database: VuforiaDatabase,
+ mock_database: CloudDatabase,
) -> None:
"""
- It is possible to use environment variables to set the credentials.
+ It is possible to use environment variables to set the
+ credentials.
"""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
new_file = tmp_path / uuid.uuid4().hex
image_data = high_quality_image.getvalue()
new_file.write_bytes(data=image_data)
diff --git a/tests/test_query_errors.py b/tests/test_query_errors.py
index 8a1af6ec..aa8df5f2 100644
--- a/tests/test_query_errors.py
+++ b/tests/test_query_errors.py
@@ -1,6 +1,4 @@
-"""
-Tests for how errors from the Cloud Reco Service are handled by the CLI.
-"""
+"""Tests for how errors from the Cloud Reco Service are handled by the CLI."""
import io
import uuid
@@ -9,21 +7,20 @@
from click.testing import CliRunner
from freezegun import freeze_time
from mock_vws import MockVWS
-from mock_vws.database import VuforiaDatabase
+from mock_vws.database import CloudDatabase
from mock_vws.states import States
from vws_cli.query import vuforia_cloud_reco
def test_authentication_failure(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
tmp_path: Path,
high_quality_image: io.BytesIO,
) -> None:
- """
- An error is given when the secret key is incorrect.
- """
- runner = CliRunner(mix_stderr=False)
+ """An error is given when the secret key is incorrect."""
+ runner = CliRunner()
new_file = tmp_path / uuid.uuid4().hex
image_data = high_quality_image.getvalue()
new_file.write_bytes(data=image_data)
@@ -46,14 +43,13 @@ def test_authentication_failure(
def test_image_too_large(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
tmp_path: Path,
png_too_large: io.BytesIO,
) -> None:
- """
- An error is given when the image is too large.
- """
- runner = CliRunner(mix_stderr=False)
+ """An error is given when the image is too large."""
+ runner = CliRunner()
new_file = tmp_path / uuid.uuid4().hex
image_data = png_too_large.getvalue()
new_file.write_bytes(data=image_data)
@@ -76,7 +72,8 @@ def test_image_too_large(
def test_bad_image(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
tmp_path: Path,
) -> None:
"""An error is given when Vuforia returns a ``BadImage`` error.
@@ -85,7 +82,7 @@ def test_bad_image(
"""
new_file = tmp_path / uuid.uuid4().hex
new_file.write_bytes(data=b"Not an image")
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
commands = [
str(object=new_file),
"--client-access-key",
@@ -108,20 +105,22 @@ def test_bad_image(
def test_inactive_project(
+ *,
high_quality_image: io.BytesIO,
tmp_path: Path,
) -> None:
"""
- An error is given if the project is inactive and the desired action cannot
+ An error is given if the project is inactive and the desired action
+ cannot
be taken because of this.
"""
new_file = tmp_path / uuid.uuid4().hex
image_data = high_quality_image.getvalue()
new_file.write_bytes(data=image_data)
- database = VuforiaDatabase(state=States.PROJECT_INACTIVE)
+ database = CloudDatabase(state=States.PROJECT_INACTIVE)
with MockVWS() as mock:
- mock.add_database(database=database)
- runner = CliRunner(mix_stderr=False)
+ mock.add_cloud_database(cloud_database=database)
+ runner = CliRunner()
commands = [
str(object=new_file),
"--client-access-key",
@@ -145,15 +144,17 @@ def test_inactive_project(
def test_request_time_too_skewed(
+ *,
high_quality_image: io.BytesIO,
- mock_database: VuforiaDatabase,
+ mock_database: CloudDatabase,
tmp_path: Path,
) -> None:
"""
- An error is given when the request time is more than 65 minutes different
+ An error is given when the request time is more than 65 minutes
+ different
from the server time.
"""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
vwq_max_time_skew = 60 * 65
leeway = 10
time_difference_from_now = vwq_max_time_skew + leeway
diff --git a/tests/test_reco_counts_report.py b/tests/test_reco_counts_report.py
new file mode 100644
index 00000000..e9411d15
--- /dev/null
+++ b/tests/test_reco_counts_report.py
@@ -0,0 +1,300 @@
+"""Tests for the ``get-database-reco-counts-report`` command."""
+
+import datetime
+import uuid
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+import pytest
+from click.testing import CliRunner
+from mock_vws import MockVWS
+from mock_vws.database import CloudDatabase
+from vws import VWS
+from vws.exceptions.custom_exceptions import RecoCountsReportNotReadyError
+
+from vws_cli import vws_group
+
+_EXPECTED_CSV = b"target_id,reco_count\r\n"
+
+# The exit code which ``click`` uses for a usage error.
+_USAGE_ERROR_EXIT_CODE = 2
+
+
+def _month_string(*, months_ago: int) -> str:
+ """Return a ``YYYY-mm`` string for a month relative to this month."""
+ now = datetime.datetime.now(tz=ZoneInfo(key="UTC"))
+ first_of_month = now.replace(day=1)
+ for _ in range(months_ago):
+ first_of_month = first_of_month.replace(day=1) - datetime.timedelta(
+ days=1,
+ )
+ return first_of_month.strftime(format="%Y-%m")
+
+
+def _base_commands(*, mock_database: CloudDatabase) -> list[str]:
+ """Return the command and credential arguments for the report
+ command.
+ """
+ return [
+ "get-database-reco-counts-report",
+ "--server-access-key",
+ mock_database.server_access_key,
+ "--server-secret-key",
+ mock_database.server_secret_key,
+ "--database-id",
+ mock_database.database_id,
+ ]
+
+
+def test_get_database_reco_counts_report(
+ *,
+ mock_database: CloudDatabase,
+) -> None:
+ """The report is written to stdout.
+
+ The ``mock_database`` fixture does not make the report available
+ immediately, so this also shows that the command waits for the report.
+ """
+ runner = CliRunner()
+ result = runner.invoke(
+ cli=vws_group,
+ args=_base_commands(mock_database=mock_database),
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 0
+ assert not result.stderr
+ assert result.stdout_bytes == _EXPECTED_CSV
+
+
+def test_report_is_not_available_immediately() -> None:
+ """The command waits for a report which is not ready to download."""
+ runner = CliRunner()
+ mock_database = CloudDatabase()
+ with MockVWS(processing_time_seconds=1) as mock:
+ mock.add_cloud_database(cloud_database=mock_database)
+ vws_client = VWS(
+ server_access_key=mock_database.server_access_key,
+ server_secret_key=mock_database.server_secret_key,
+ )
+ no_wait_result = runner.invoke(
+ cli=vws_group,
+ args=[*_base_commands(mock_database=mock_database), "--no-wait"],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert no_wait_result.exit_code == 0
+ presigned_url = no_wait_result.stdout.strip()
+ with pytest.raises(expected_exception=RecoCountsReportNotReadyError):
+ vws_client.download_reco_counts_report(
+ presigned_url=presigned_url,
+ )
+
+ result = runner.invoke(
+ cli=vws_group,
+ args=_base_commands(mock_database=mock_database),
+ catch_exceptions=False,
+ color=True,
+ )
+
+ assert result.exit_code == 0
+ assert result.stdout_bytes == _EXPECTED_CSV
+
+
+def test_output_file(*, tmp_path: Path) -> None:
+ """The report is written to the path given by ``--output``."""
+ runner = CliRunner()
+ mock_database = CloudDatabase()
+ output_file_path = tmp_path / uuid.uuid4().hex
+ with MockVWS(processing_time_seconds=0) as mock:
+ mock.add_cloud_database(cloud_database=mock_database)
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ *_base_commands(mock_database=mock_database),
+ "--output",
+ str(object=output_file_path),
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+
+ assert result.exit_code == 0
+ assert not result.stdout
+ assert output_file_path.read_bytes() == _EXPECTED_CSV
+
+
+def test_no_wait() -> None:
+ """``--no-wait`` shows the URL to download the report from."""
+ runner = CliRunner()
+ mock_database = CloudDatabase()
+ with MockVWS(processing_time_seconds=0) as mock:
+ mock.add_cloud_database(cloud_database=mock_database)
+ vws_client = VWS(
+ server_access_key=mock_database.server_access_key,
+ server_secret_key=mock_database.server_secret_key,
+ )
+ result = runner.invoke(
+ cli=vws_group,
+ args=[*_base_commands(mock_database=mock_database), "--no-wait"],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 0
+ presigned_url = result.stdout.strip()
+ report = vws_client.download_reco_counts_report(
+ presigned_url=presigned_url,
+ )
+
+ assert report.raw_csv == _EXPECTED_CSV
+
+
+def test_no_wait_with_output_file(
+ *,
+ mock_database: CloudDatabase,
+ tmp_path: Path,
+) -> None:
+ """``--output`` cannot be used with ``--no-wait``."""
+ runner = CliRunner()
+ output_file_path = tmp_path / uuid.uuid4().hex
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ *_base_commands(mock_database=mock_database),
+ "--no-wait",
+ "--output",
+ str(object=output_file_path),
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == _USAGE_ERROR_EXIT_CODE
+ assert "--output cannot be used with --no-wait." in result.stderr
+ assert not output_file_path.exists()
+
+
+def test_previous_month() -> None:
+ """A report can be requested for the previous month."""
+ runner = CliRunner()
+ mock_database = CloudDatabase()
+ with MockVWS(processing_time_seconds=0) as mock:
+ mock.add_cloud_database(cloud_database=mock_database)
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ *_base_commands(mock_database=mock_database),
+ "--month",
+ _month_string(months_ago=1),
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+
+ assert result.exit_code == 0
+ assert result.stdout_bytes == _EXPECTED_CSV
+
+
+def test_month_is_not_in_the_yyyy_mm_form(
+ *,
+ mock_database: CloudDatabase,
+) -> None:
+ """An error is shown for a month which is not in the ``YYYY-mm``
+ form.
+ """
+ runner = CliRunner()
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ *_base_commands(mock_database=mock_database),
+ "--month",
+ "not-a-month",
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == _USAGE_ERROR_EXIT_CODE
+ expected_message = '"not-a-month" is not a month in the YYYY-mm form.'
+ assert expected_message in result.stderr
+
+
+def test_month_out_of_range(*, mock_database: CloudDatabase) -> None:
+ """An error is shown for a month which Vuforia does not accept.
+
+ Vuforia accepts only the current month and the previous month.
+ """
+ runner = CliRunner()
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ *_base_commands(mock_database=mock_database),
+ "--month",
+ _month_string(months_ago=2),
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 1
+ expected_stderr = (
+ "Error: The request made to Vuforia was invalid and could not be "
+ "processed. Check the given parameters.\n"
+ )
+ assert result.stderr == expected_stderr
+ assert not result.stdout
+
+
+def test_database_id_does_not_match(
+ *,
+ mock_database: CloudDatabase,
+) -> None:
+ """An error is shown when the given database ID is not the
+ database's.
+ """
+ runner = CliRunner()
+ commands = [
+ "get-database-reco-counts-report",
+ "--server-access-key",
+ mock_database.server_access_key,
+ "--server-secret-key",
+ mock_database.server_secret_key,
+ "--database-id",
+ uuid.uuid4().hex,
+ ]
+ result = runner.invoke(
+ cli=vws_group,
+ args=commands,
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 1
+ expected_stderr = (
+ "Error: The given secret key was incorrect, or the given database ID "
+ "is not the ID of the database which the given server keys belong "
+ "to.\n"
+ )
+ assert result.stderr == expected_stderr
+ assert not result.stdout
+
+
+def test_timeout_reached() -> None:
+ """An error is shown when the report is not generated in time."""
+ runner = CliRunner()
+ mock_database = CloudDatabase()
+ timeout_seconds = 0.1
+ with MockVWS(processing_time_seconds=60) as mock:
+ mock.add_cloud_database(cloud_database=mock_database)
+ result = runner.invoke(
+ cli=vws_group,
+ args=[
+ *_base_commands(mock_database=mock_database),
+ "--timeout-seconds",
+ str(object=timeout_seconds),
+ "--seconds-between-requests",
+ "0.05",
+ ],
+ catch_exceptions=False,
+ color=True,
+ )
+
+ assert result.exit_code == 1
+ assert result.stderr == f"Timeout of {timeout_seconds} seconds reached.\n"
+ assert not result.stdout
diff --git a/tests/test_vumark.py b/tests/test_vumark.py
new file mode 100644
index 00000000..91136fa5
--- /dev/null
+++ b/tests/test_vumark.py
@@ -0,0 +1,269 @@
+"""Tests for the ``vumark`` CLI command."""
+
+import uuid
+from pathlib import Path
+
+import click
+import pytest
+from click.testing import CliRunner
+from mock_vws import MockVWS
+from mock_vws.database import CloudDatabase, VuMarkDatabase
+from mock_vws.target import VuMarkTarget
+
+from vws_cli import vumark as vumark_module
+
+generate_vumark = vumark_module.generate_vumark
+
+
+class TestGenerateVuMark:
+ """Tests for ``vumark``."""
+
+ @staticmethod
+ @pytest.mark.parametrize(
+ argnames=("format_name", "expected_prefix"),
+ argvalues=[
+ pytest.param("png", b"\x89PNG\r\n\x1a\n", id="png"),
+ pytest.param("svg", b"<", id="svg"),
+ pytest.param("pdf", b"%PDF", id="pdf"),
+ ],
+ )
+ def test_generate_vumark_format(
+ vumark_database: VuMarkDatabase,
+ vumark_target: VuMarkTarget,
+ tmp_path: Path,
+ format_name: str,
+ expected_prefix: bytes,
+ ) -> None:
+ """The returned file matches the requested format."""
+ runner = CliRunner()
+ output_file = tmp_path / f"output.{format_name}"
+ commands = [
+ "--target-id",
+ vumark_target.target_id,
+ "--instance-id",
+ "12345",
+ "--format",
+ format_name,
+ "--output",
+ str(object=output_file),
+ "--server-access-key",
+ vumark_database.server_access_key,
+ "--server-secret-key",
+ vumark_database.server_secret_key,
+ ]
+ result = runner.invoke(
+ cli=generate_vumark,
+ args=commands,
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 0
+ assert output_file.read_bytes().startswith(expected_prefix)
+
+ @staticmethod
+ def test_default_format_is_png(
+ vumark_database: VuMarkDatabase,
+ vumark_target: VuMarkTarget,
+ tmp_path: Path,
+ ) -> None:
+ """The default output format is png."""
+ runner = CliRunner()
+ output_file = tmp_path / "output.png"
+ commands = [
+ "--target-id",
+ vumark_target.target_id,
+ "--instance-id",
+ "12345",
+ "--output",
+ str(object=output_file),
+ "--server-access-key",
+ vumark_database.server_access_key,
+ "--server-secret-key",
+ vumark_database.server_secret_key,
+ ]
+ result = runner.invoke(
+ cli=generate_vumark,
+ args=commands,
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 0
+ assert output_file.exists()
+ assert output_file.read_bytes().startswith(b"\x89PNG\r\n\x1a\n")
+
+ @staticmethod
+ def test_unknown_target(
+ vumark_database: VuMarkDatabase,
+ tmp_path: Path,
+ ) -> None:
+ """An error is shown when the target ID does not exist."""
+ nonexistent_target_id = uuid.uuid4().hex
+ runner = CliRunner()
+ output_file = tmp_path / "output.png"
+ commands = [
+ "--target-id",
+ nonexistent_target_id,
+ "--instance-id",
+ "12345",
+ "--output",
+ str(object=output_file),
+ "--server-access-key",
+ vumark_database.server_access_key,
+ "--server-secret-key",
+ vumark_database.server_secret_key,
+ ]
+ result = runner.invoke(
+ cli=generate_vumark,
+ args=commands,
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 1
+ expected_stderr = (
+ f'Error: Target "{nonexistent_target_id}" does not exist.\n'
+ )
+ assert result.stderr == expected_stderr
+
+ @staticmethod
+ def test_invalid_instance_id(
+ vumark_database: VuMarkDatabase,
+ vumark_target: VuMarkTarget,
+ tmp_path: Path,
+ ) -> None:
+ """An error is shown when the instance ID is invalid."""
+ runner = CliRunner()
+ output_file = tmp_path / "output.png"
+ commands = [
+ "--target-id",
+ vumark_target.target_id,
+ "--instance-id",
+ "",
+ "--output",
+ str(object=output_file),
+ "--server-access-key",
+ vumark_database.server_access_key,
+ "--server-secret-key",
+ vumark_database.server_secret_key,
+ ]
+ result = runner.invoke(
+ cli=generate_vumark,
+ args=commands,
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 1
+ expected_stderr = "Error: The given instance ID is invalid.\n"
+ assert result.stderr == expected_stderr
+
+ @staticmethod
+ def test_target_not_in_success_state(
+ tmp_path: Path,
+ ) -> None:
+ """An error is shown when the target is not in the success
+ state.
+ """
+ processing_target = VuMarkTarget(
+ name="processing-target",
+ processing_time_seconds=9999,
+ )
+ database = VuMarkDatabase(vumark_targets={processing_target})
+ with MockVWS() as mock:
+ mock.add_vumark_database(vumark_database=database)
+ target_id = processing_target.target_id
+ runner = CliRunner()
+ output_file = tmp_path / "output.png"
+ commands = [
+ "--target-id",
+ target_id,
+ "--instance-id",
+ "12345",
+ "--output",
+ str(object=output_file),
+ "--server-access-key",
+ database.server_access_key,
+ "--server-secret-key",
+ database.server_secret_key,
+ ]
+ result = runner.invoke(
+ cli=generate_vumark,
+ args=commands,
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 1
+ expected_stderr = (
+ f'Error: The target "{target_id}" is not in the success '
+ "state and cannot be used to generate a VuMark instance.\n"
+ )
+ assert result.stderr == expected_stderr
+
+ @staticmethod
+ def test_authentication_failure(
+ vumark_database: VuMarkDatabase,
+ vumark_target: VuMarkTarget,
+ tmp_path: Path,
+ ) -> None:
+ """An error is shown on authentication failure."""
+ runner = CliRunner()
+ output_file = tmp_path / "output.png"
+ commands = [
+ "--target-id",
+ vumark_target.target_id,
+ "--instance-id",
+ "12345",
+ "--output",
+ str(object=output_file),
+ "--server-access-key",
+ vumark_database.server_access_key,
+ "--server-secret-key",
+ "wrong-secret-key",
+ ]
+ result = runner.invoke(
+ cli=generate_vumark,
+ args=commands,
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 1
+ expected_stderr = "The given secret key was incorrect.\n"
+ assert result.stderr == expected_stderr
+
+
+@pytest.mark.parametrize(argnames="invalid_format", argvalues=["bmp", "gif"])
+def test_invalid_format(
+ mock_database: CloudDatabase,
+ tmp_path: Path,
+ invalid_format: str,
+) -> None:
+ """An error is shown for an unrecognized format choice."""
+ runner = CliRunner()
+ output_file = tmp_path / "output"
+ commands = [
+ "--target-id",
+ "some-target-id",
+ "--instance-id",
+ "12345",
+ "--format",
+ invalid_format,
+ "--output",
+ str(object=output_file),
+ "--server-access-key",
+ mock_database.server_access_key,
+ "--server-secret-key",
+ mock_database.server_secret_key,
+ ]
+ result = runner.invoke(
+ cli=generate_vumark,
+ args=commands,
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == click.UsageError.exit_code
+ expected_output = (
+ "Usage: vumark [OPTIONS]\n"
+ "Try 'vumark --help' for help.\n"
+ "\n"
+ f"Error: Invalid value for '--format': '{invalid_format}' is not "
+ "one of 'png', 'svg', 'pdf'.\n"
+ )
+ assert result.output == expected_output
diff --git a/tests/test_vws_commands.py b/tests/test_vws_commands.py
index 5df470a8..2b21a34f 100644
--- a/tests/test_vws_commands.py
+++ b/tests/test_vws_commands.py
@@ -1,9 +1,8 @@
# pylint:disable=too-many-lines
-"""
-Tests for VWS CLI commands.
-"""
+"""Tests for VWS CLI commands."""
import base64
+import datetime
import io
import secrets
import uuid
@@ -11,11 +10,12 @@
from textwrap import dedent
import pytest
+import requests
import yaml
from click.testing import CliRunner
from freezegun import freeze_time
from mock_vws import MockVWS
-from mock_vws.database import VuforiaDatabase
+from mock_vws.database import CloudDatabase
from vws import VWS, CloudRecoService
from vws.reports import TargetStatuses
@@ -23,13 +23,12 @@
def test_get_database_summary_report(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
vws_client: VWS,
high_quality_image: io.BytesIO,
) -> None:
- """
- It is possible to get a database summary report.
- """
+ """It is possible to get a database summary report."""
runner = CliRunner()
for name in ("a", "b"):
vws_client.add_target(
@@ -73,13 +72,12 @@ def test_get_database_summary_report(
def test_list_targets(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
vws_client: VWS,
high_quality_image: io.BytesIO,
) -> None:
- """
- It is possible to get a list of targets in the database.
- """
+ """It is possible to get a list of targets in the database."""
runner = CliRunner()
target_id_1 = vws_client.add_target(
name="x1",
@@ -116,13 +114,12 @@ def test_list_targets(
def test_get_target_record(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
vws_client: VWS,
high_quality_image: io.BytesIO,
) -> None:
- """
- It is possible to get a target record.
- """
+ """It is possible to get a target record."""
runner = CliRunner()
target_id = vws_client.add_target(
name="x",
@@ -160,13 +157,12 @@ def test_get_target_record(
def test_get_target_summary_report(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
vws_client: VWS,
high_quality_image: io.BytesIO,
) -> None:
- """
- It is possible to get a target summary report.
- """
+ """It is possible to get a target summary report."""
runner = CliRunner()
upload_date = "2015-04-29"
with freeze_time(time_to_freeze=upload_date):
@@ -210,13 +206,12 @@ def test_get_target_summary_report(
def test_delete_target(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
vws_client: VWS,
high_quality_image: io.BytesIO,
) -> None:
- """
- It is possible to delete a target.
- """
+ """It is possible to delete a target."""
runner = CliRunner()
target_id = vws_client.add_target(
name="x",
@@ -248,13 +243,12 @@ def test_delete_target(
def test_get_duplicate_targets(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
vws_client: VWS,
high_quality_image: io.BytesIO,
) -> None:
- """
- It is possible to get a list of duplicate targets.
- """
+ """It is possible to get a list of duplicate targets."""
runner = CliRunner()
target_id = vws_client.add_target(
name="x",
@@ -295,22 +289,193 @@ def test_get_duplicate_targets(
assert result_data == expected_result_data
+class TestDefaultRequestTimeout:
+ """Tests for the default request timeout."""
+
+ @staticmethod
+ @pytest.mark.parametrize(
+ argnames=("response_delay_seconds", "expect_timeout"),
+ argvalues=[(29, False), (31, True)],
+ )
+ def test_default_timeout(
+ *,
+ high_quality_image: io.BytesIO,
+ tmp_path: Path,
+ response_delay_seconds: int,
+ expect_timeout: bool,
+ ) -> None:
+ """At 29 seconds there is no error; at 31 seconds there is a
+ timeout.
+ """
+ runner = CliRunner()
+ new_file = tmp_path / uuid.uuid4().hex
+ image_data = high_quality_image.getvalue()
+ new_file.write_bytes(data=image_data)
+ with (
+ freeze_time() as frozen_datetime,
+ MockVWS(
+ response_delay_seconds=response_delay_seconds,
+ sleep_fn=lambda seconds: (
+ frozen_datetime.tick(
+ delta=datetime.timedelta(seconds=seconds),
+ ),
+ None,
+ )[1],
+ ) as mock,
+ ):
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
+ commands = [
+ "add-target",
+ "--name",
+ uuid.uuid4().hex,
+ "--width",
+ "1",
+ "--image",
+ str(object=new_file),
+ "--server-access-key",
+ database.server_access_key,
+ "--server-secret-key",
+ database.server_secret_key,
+ ]
+
+ if expect_timeout:
+ with pytest.raises(
+ expected_exception=requests.exceptions.Timeout,
+ ):
+ runner.invoke(
+ cli=vws_group,
+ args=commands,
+ catch_exceptions=False,
+ color=True,
+ )
+ else:
+ result = runner.invoke(
+ cli=vws_group,
+ args=commands,
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 0
+
+
+class TestCustomRequestTimeout:
+ """Tests for custom request timeout options."""
+
+ @staticmethod
+ def test_custom_timeout(
+ *,
+ high_quality_image: io.BytesIO,
+ tmp_path: Path,
+ ) -> None:
+ """Custom connection and read timeouts are respected."""
+ runner = CliRunner()
+ new_file = tmp_path / uuid.uuid4().hex
+ image_data = high_quality_image.getvalue()
+ new_file.write_bytes(data=image_data)
+ with (
+ freeze_time() as frozen_datetime,
+ MockVWS(
+ response_delay_seconds=5,
+ sleep_fn=lambda seconds: (
+ frozen_datetime.tick(
+ delta=datetime.timedelta(seconds=seconds),
+ ),
+ None,
+ )[1],
+ ) as mock,
+ ):
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
+ commands = [
+ "add-target",
+ "--name",
+ uuid.uuid4().hex,
+ "--width",
+ "1",
+ "--image",
+ str(object=new_file),
+ "--server-access-key",
+ database.server_access_key,
+ "--server-secret-key",
+ database.server_secret_key,
+ "--read-timeout-seconds",
+ "1",
+ ]
+
+ with pytest.raises(
+ expected_exception=requests.exceptions.Timeout,
+ ):
+ runner.invoke(
+ cli=vws_group,
+ args=commands,
+ catch_exceptions=False,
+ color=True,
+ )
+
+ @staticmethod
+ def test_custom_timeout_no_error(
+ *,
+ high_quality_image: io.BytesIO,
+ tmp_path: Path,
+ ) -> None:
+ """A sufficiently large timeout does not cause an error."""
+ runner = CliRunner()
+ new_file = tmp_path / uuid.uuid4().hex
+ image_data = high_quality_image.getvalue()
+ new_file.write_bytes(data=image_data)
+ with (
+ freeze_time() as frozen_datetime,
+ MockVWS(
+ response_delay_seconds=5,
+ sleep_fn=lambda seconds: (
+ frozen_datetime.tick(
+ delta=datetime.timedelta(seconds=seconds),
+ ),
+ None,
+ )[1],
+ ) as mock,
+ ):
+ database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=database)
+ commands = [
+ "add-target",
+ "--name",
+ uuid.uuid4().hex,
+ "--width",
+ "1",
+ "--image",
+ str(object=new_file),
+ "--server-access-key",
+ database.server_access_key,
+ "--server-secret-key",
+ database.server_secret_key,
+ "--read-timeout-seconds",
+ "60",
+ ]
+
+ result = runner.invoke(
+ cli=vws_group,
+ args=commands,
+ catch_exceptions=False,
+ color=True,
+ )
+ assert result.exit_code == 0
+
+
class TestAddTarget:
- """
- Tests for ``vws add-target``.
- """
+ """Tests for ``vws add-target``."""
@staticmethod
def test_add_target(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
vws_client: VWS,
high_quality_image: io.BytesIO,
tmp_path: Path,
cloud_reco_client: CloudRecoService,
) -> None:
- """
- It is possible to add a target.
- """
+ """It is possible to add a target."""
runner = CliRunner()
new_file = tmp_path / uuid.uuid4().hex
name = uuid.uuid4().hex
@@ -354,13 +519,15 @@ def test_add_target(
@staticmethod
def test_image_file_does_not_exist(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
tmp_path: Path,
) -> None:
"""
- An appropriate error is given if the given image file does not exist.
+ An appropriate error is given if the given image file does not
+ exist.
"""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
does_not_exist_file = tmp_path / uuid.uuid4().hex
commands = [
"add-target",
@@ -387,7 +554,7 @@ def test_image_file_does_not_exist(
expected_stderr = dedent(
text=f"""\
Usage: vws add-target [OPTIONS]
- Try 'vws add-target -h' for help.
+ Try 'vws add-target --help' for help.
Error: Invalid value for '--image': File '{does_not_exist_file}' does not exist.
""",
@@ -396,14 +563,16 @@ def test_image_file_does_not_exist(
@staticmethod
def test_image_file_is_dir(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
tmp_path: Path,
) -> None:
"""
- An appropriate error is given if the given image file path points to a
+ An appropriate error is given if the given image file path
+ points to a
directory.
"""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
commands = [
"add-target",
"--name",
@@ -429,7 +598,7 @@ def test_image_file_is_dir(
expected_stderr = dedent(
text=f"""\
Usage: vws add-target [OPTIONS]
- Try 'vws add-target -h' for help.
+ Try 'vws add-target --help' for help.
Error: Invalid value for '--image': File '{tmp_path}' is a directory.
""",
@@ -438,15 +607,14 @@ def test_image_file_is_dir(
@staticmethod
def test_relative_path(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
vws_client: VWS,
high_quality_image: io.BytesIO,
tmp_path: Path,
) -> None:
- """
- Image file paths are resolved.
- """
- runner = CliRunner(mix_stderr=False)
+ """Image file paths are resolved."""
+ runner = CliRunner()
new_filename = uuid.uuid4().hex
original_image_file = tmp_path / "foo"
image_data = high_quality_image.getvalue()
@@ -481,15 +649,14 @@ def test_relative_path(
@staticmethod
def test_custom_metadata(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
cloud_reco_client: CloudRecoService,
vws_client: VWS,
tmp_path: Path,
high_quality_image: io.BytesIO,
) -> None:
- """
- Custom metadata can be given.
- """
+ """Custom metadata can be given."""
runner = CliRunner()
new_file = tmp_path / uuid.uuid4().hex
name = uuid.uuid4().hex
@@ -540,17 +707,15 @@ def test_custom_metadata(
],
)
def test_custom_active_flag(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
vws_client: VWS,
high_quality_image: io.BytesIO,
tmp_path: Path,
active_flag_given: str,
- *,
active_flag_expected: bool,
) -> None:
- """
- The Active Flag of the new target can be chosen.
- """
+ """The Active Flag of the new target can be chosen."""
runner = CliRunner()
new_file = tmp_path / uuid.uuid4().hex
image_data = high_quality_image.getvalue()
@@ -585,18 +750,18 @@ def test_custom_active_flag(
class TestWaitForTargetProcessed:
- """
- Tests for ``vws wait-for-target-processed``.
- """
+ """Tests for ``vws wait-for-target-processed``."""
@staticmethod
def test_wait_for_target_processed(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
vws_client: VWS,
high_quality_image: io.BytesIO,
) -> None:
"""
- It is possible to use a command to wait for a target to be processed.
+ It is possible to use a command to wait for a target to be
+ processed.
"""
runner = CliRunner()
target_id = vws_client.add_target(
@@ -632,13 +797,11 @@ def test_wait_for_target_processed(
def test_default_seconds_between_requests(
high_quality_image: io.BytesIO,
) -> None:
- """
- By default, 0.2 seconds are waited between polling requests.
- """
+ """By default, 0.2 seconds are waited between polling requests."""
runner = CliRunner()
with MockVWS(processing_time_seconds=0.5) as mock:
- mock_database = VuforiaDatabase()
- mock.add_database(database=mock_database)
+ mock_database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=mock_database)
vws_client = VWS(
server_access_key=mock_database.server_access_key,
server_secret_key=mock_database.server_secret_key,
@@ -702,12 +865,13 @@ def test_custom_seconds_between_requests(
high_quality_image: io.BytesIO,
) -> None:
"""
- It is possible to customize the time waited between polling requests.
+ It is possible to customize the time waited between polling
+ requests.
"""
runner = CliRunner()
with MockVWS(processing_time_seconds=0.5) as mock:
- mock_database = VuforiaDatabase()
- mock.add_database(database=mock_database)
+ mock_database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=mock_database)
vws_client = VWS(
server_access_key=mock_database.server_access_key,
server_secret_key=mock_database.server_secret_key,
@@ -766,12 +930,13 @@ def test_custom_seconds_between_requests(
assert report.request_usage == expected_requests
@staticmethod
- def test_custom_seconds_too_small(mock_database: VuforiaDatabase) -> None:
+ def test_custom_seconds_too_small(mock_database: CloudDatabase) -> None:
"""
- The minimum valid value for ``--seconds-between-requests`` is 0.05
+ The minimum valid value for ``--seconds-between-requests`` is
+ 0.05
seconds.
"""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
commands = [
"wait-for-target-processed",
"--target-id",
@@ -796,13 +961,11 @@ def test_custom_seconds_too_small(mock_database: VuforiaDatabase) -> None:
@staticmethod
def test_custom_timeout(high_quality_image: io.BytesIO) -> None:
- """
- It is possible to set a maximum timeout.
- """
- runner = CliRunner(mix_stderr=False)
+ """It is possible to set a maximum timeout."""
+ runner = CliRunner()
with MockVWS(processing_time_seconds=0.5) as mock:
- mock_database = VuforiaDatabase()
- mock.add_database(database=mock_database)
+ mock_database = CloudDatabase()
+ mock.add_cloud_database(cloud_database=mock_database)
vws_client = VWS(
server_access_key=mock_database.server_access_key,
server_secret_key=mock_database.server_secret_key,
@@ -861,11 +1024,12 @@ def test_custom_timeout(high_quality_image: io.BytesIO) -> None:
assert report.status != TargetStatuses.PROCESSING
@staticmethod
- def test_custom_timeout_too_small(mock_database: VuforiaDatabase) -> None:
+ def test_custom_timeout_too_small(mock_database: CloudDatabase) -> None:
"""
- The minimum valid value for ``--timeout-seconds`` is 0.05 seconds.
+ The minimum valid value for ``--timeout-seconds`` is 0.05
+ seconds.
"""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
commands = [
"wait-for-target-processed",
"--target-id",
@@ -889,24 +1053,20 @@ def test_custom_timeout_too_small(mock_database: VuforiaDatabase) -> None:
class TestUpdateTarget:
- """
- Tests for ``vws update-target``.
- """
+ """Tests for ``vws update-target``."""
@staticmethod
def test_update_target(
*,
- mock_database: VuforiaDatabase,
+ mock_database: CloudDatabase,
vws_client: VWS,
high_quality_image: io.BytesIO,
tmp_path: Path,
cloud_reco_client: CloudRecoService,
different_high_quality_image: io.BytesIO,
) -> None:
- """
- It is possible to update a target.
- """
- runner = CliRunner(mix_stderr=False)
+ """It is possible to update a target."""
+ runner = CliRunner()
old_name = uuid.uuid4().hex
old_width = secrets.choice(seq=range(1, 5000)) / 100
target_id = vws_client.add_target(
@@ -992,14 +1152,13 @@ def test_update_target(
@staticmethod
def test_no_fields_given(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
vws_client: VWS,
high_quality_image: io.BytesIO,
) -> None:
- """
- It is possible to give no update fields.
- """
- runner = CliRunner(mix_stderr=False)
+ """It is possible to give no update fields."""
+ runner = CliRunner()
old_name = uuid.uuid4().hex
old_width = secrets.choice(seq=range(1, 5000)) / 100
target_id = vws_client.add_target(
@@ -1031,13 +1190,15 @@ def test_no_fields_given(
@staticmethod
def test_image_file_does_not_exist(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
vws_client: VWS,
high_quality_image: io.BytesIO,
tmp_path: Path,
) -> None:
"""
- An appropriate error is given if the given image file does not exist.
+ An appropriate error is given if the given image file does not
+ exist.
"""
target_id = vws_client.add_target(
name="x",
@@ -1047,7 +1208,7 @@ def test_image_file_does_not_exist(
application_metadata=None,
)
vws_client.wait_for_target_processed(target_id=target_id)
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
does_not_exist_file = tmp_path / uuid.uuid4().hex
commands = [
"update-target",
@@ -1072,7 +1233,7 @@ def test_image_file_does_not_exist(
expected_stderr = dedent(
text=f"""\
Usage: vws update-target [OPTIONS]
- Try 'vws update-target -h' for help.
+ Try 'vws update-target --help' for help.
Error: Invalid value for '--image': File '{does_not_exist_file}' does not exist.
""",
@@ -1081,13 +1242,15 @@ def test_image_file_does_not_exist(
@staticmethod
def test_image_file_is_dir(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
vws_client: VWS,
high_quality_image: io.BytesIO,
tmp_path: Path,
) -> None:
"""
- An appropriate error is given if the given image file path points to a
+ An appropriate error is given if the given image file path
+ points to a
directory.
"""
target_id = vws_client.add_target(
@@ -1098,7 +1261,7 @@ def test_image_file_is_dir(
application_metadata=None,
)
vws_client.wait_for_target_processed(target_id=target_id)
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
commands = [
"update-target",
"--target-id",
@@ -1122,7 +1285,7 @@ def test_image_file_is_dir(
expected_stderr = dedent(
text=f"""\
Usage: vws update-target [OPTIONS]
- Try 'vws update-target -h' for help.
+ Try 'vws update-target --help' for help.
Error: Invalid value for '--image': File '{tmp_path}' is a directory.
""",
@@ -1131,15 +1294,14 @@ def test_image_file_is_dir(
@staticmethod
def test_relative_path(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
vws_client: VWS,
high_quality_image: io.BytesIO,
tmp_path: Path,
) -> None:
- """
- Image file paths are resolved.
- """
- runner = CliRunner(mix_stderr=False)
+ """Image file paths are resolved."""
+ runner = CliRunner()
target_id = vws_client.add_target(
name="x",
width=1,
@@ -1179,12 +1341,13 @@ def test_relative_path(
def test_custom_base_url() -> None:
"""
- It is possible to use the API to connect to a database under a custom VWS
+ It is possible to use the API to connect to a database under a
+ custom VWS
URL.
"""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
base_vws_url = "http://example.com"
- mock_database = VuforiaDatabase()
+ mock_database = CloudDatabase()
commands = [
"list-targets",
"--server-access-key",
@@ -1195,7 +1358,7 @@ def test_custom_base_url() -> None:
base_vws_url,
]
with MockVWS(base_vws_url=base_vws_url) as mock:
- mock.add_database(database=mock_database)
+ mock.add_cloud_database(cloud_database=mock_database)
result = runner.invoke(
cli=vws_group,
args=commands,
diff --git a/tests/test_vws_errors.py b/tests/test_vws_errors.py
index 884e1e2e..ebf8b60e 100644
--- a/tests/test_vws_errors.py
+++ b/tests/test_vws_errors.py
@@ -1,6 +1,4 @@
-"""
-Tests for how errors from VWS are handled by the CLI.
-"""
+"""Tests for how errors from VWS are handled by the CLI."""
import io
import uuid
@@ -9,19 +7,20 @@
from click.testing import CliRunner
from freezegun import freeze_time
from mock_vws import MockVWS
-from mock_vws.database import VuforiaDatabase
+from mock_vws.database import CloudDatabase
from mock_vws.states import States
from vws import VWS
from vws_cli import vws_group
-def test_target_id_does_not_exist(mock_database: VuforiaDatabase) -> None:
+def test_target_id_does_not_exist(mock_database: CloudDatabase) -> None:
"""
- Commands which take a target ID show an error if that does not map to a
+ Commands which take a target ID show an error if that does not map
+ to a
target in the database.
"""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
for command_name, command in vws_group.commands.items():
if "target_id" in [option.name for option in command.params]:
args = [
@@ -43,7 +42,8 @@ def test_target_id_does_not_exist(mock_database: VuforiaDatabase) -> None:
def test_bad_image(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
tmp_path: Path,
) -> None:
"""An error is given when Vuforia returns a ``BadImage`` error.
@@ -52,7 +52,7 @@ def test_bad_image(
"""
new_file = tmp_path / uuid.uuid4().hex
new_file.write_bytes(data=b"Not an image")
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
args = [
"add-target",
"--name",
@@ -76,19 +76,22 @@ def test_bad_image(
def test_fail_bad_request(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
high_quality_image: io.BytesIO,
tmp_path: Path,
) -> None:
- """An error is given when Vuforia returns a ``Fail`` error with a ``400``
- error code. For example, when the given server access key does not exist.
+ """An error is given when Vuforia returns a ``Fail`` error with a
+ ``400``
+ error code. For example, when the given server access key does not
+ exist.
With ``vws_python`` we cannot get a (guaranteed) 500 error or 422 response
with a ``Fail`` error.
"""
new_file = tmp_path / uuid.uuid4().hex
new_file.write_bytes(data=high_quality_image.getvalue())
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
args = [
"add-target",
"--name",
@@ -114,16 +117,15 @@ def test_fail_bad_request(
def test_metadata_too_large(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
high_quality_image: io.BytesIO,
tmp_path: Path,
) -> None:
- """
- An error is given when the given metadata is too large.
- """
+ """An error is given when the given metadata is too large."""
new_file = tmp_path / uuid.uuid4().hex
new_file.write_bytes(data=high_quality_image.getvalue())
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
args = [
"add-target",
"--name",
@@ -147,14 +149,13 @@ def test_metadata_too_large(
def test_image_too_large(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
png_too_large: io.BytesIO,
tmp_path: Path,
) -> None:
- """
- An error is given when the given image is too large.
- """
- runner = CliRunner(mix_stderr=False)
+ """An error is given when the given image is too large."""
+ runner = CliRunner()
new_file = tmp_path / uuid.uuid4().hex
image_data = png_too_large.getvalue()
new_file.write_bytes(data=image_data)
@@ -184,13 +185,15 @@ def test_image_too_large(
def test_target_name_exist(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
vws_client: VWS,
high_quality_image: io.BytesIO,
tmp_path: Path,
) -> None:
"""
- An error is given when there is already a target with the given name.
+ An error is given when there is already a target with the given
+ name.
"""
name = "foobar"
vws_client.add_target(
@@ -201,7 +204,7 @@ def test_target_name_exist(
application_metadata=None,
)
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
new_file = tmp_path / uuid.uuid4().hex
image_data = high_quality_image.getvalue()
new_file.write_bytes(data=image_data)
@@ -231,20 +234,22 @@ def test_target_name_exist(
def test_project_inactive(
+ *,
high_quality_image: io.BytesIO,
tmp_path: Path,
) -> None:
"""
- An error is given if the project is inactive and the desired action cannot
+ An error is given if the project is inactive and the desired action
+ cannot
be taken because of this.
"""
new_file = tmp_path / uuid.uuid4().hex
image_data = high_quality_image.getvalue()
new_file.write_bytes(data=image_data)
- database = VuforiaDatabase(state=States.PROJECT_INACTIVE)
+ database = CloudDatabase(state=States.PROJECT_INACTIVE)
with MockVWS() as mock:
- mock.add_database(database=database)
- runner = CliRunner(mix_stderr=False)
+ mock.add_cloud_database(cloud_database=database)
+ runner = CliRunner()
commands = [
"add-target",
"--name",
@@ -273,16 +278,63 @@ def test_project_inactive(
assert not result.stdout
+def test_project_has_no_api_access(
+ *,
+ high_quality_image: io.BytesIO,
+ tmp_path: Path,
+) -> None:
+ """
+ An error is given if the project is not allowed to make API
+ requests.
+ """
+ new_file = tmp_path / uuid.uuid4().hex
+ image_data = high_quality_image.getvalue()
+ new_file.write_bytes(data=image_data)
+ database = CloudDatabase(state=States.PROJECT_HAS_NO_API_ACCESS)
+ with MockVWS() as mock:
+ mock.add_cloud_database(cloud_database=database)
+ runner = CliRunner()
+ commands = [
+ "add-target",
+ "--name",
+ "foo",
+ "--width",
+ "0.1",
+ "--image",
+ str(object=new_file),
+ "--server-access-key",
+ database.server_access_key,
+ "--server-secret-key",
+ database.server_secret_key,
+ ]
+ result = runner.invoke(
+ cli=vws_group,
+ args=commands,
+ catch_exceptions=False,
+ color=True,
+ )
+
+ assert result.exit_code == 1
+ expected_stderr = (
+ "Error: The request could not be completed because this database is "
+ "not allowed to make API requests.\n"
+ )
+ assert result.stderr == expected_stderr
+ assert not result.stdout
+
+
def test_unknown_vws_error(
- mock_database: VuforiaDatabase,
+ *,
+ mock_database: CloudDatabase,
high_quality_image: io.BytesIO,
tmp_path: Path,
) -> None:
"""
- When an unknown VWS error is given, e.g. what is given when some bad names
+ When an unknown VWS error is given, e.g. what is given when some bad
+ names
are given, an error is given.
"""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
new_file = tmp_path / uuid.uuid4().hex
image_data = high_quality_image.getvalue()
new_file.write_bytes(data=image_data)
@@ -318,14 +370,16 @@ def test_unknown_vws_error(
def test_target_status_processing(
+ *,
vws_client: VWS,
high_quality_image: io.BytesIO,
- mock_database: VuforiaDatabase,
+ mock_database: CloudDatabase,
) -> None:
"""
- An error is given when trying to delete a target which is processing.
+ An error is given when trying to delete a target which is
+ processing.
"""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
target_id = vws_client.add_target(
name="x",
@@ -361,15 +415,17 @@ def test_target_status_processing(
def test_target_status_not_success(
+ *,
vws_client: VWS,
high_quality_image: io.BytesIO,
- mock_database: VuforiaDatabase,
+ mock_database: CloudDatabase,
) -> None:
"""
- An error is given when updating a target which has a status which is not
+ An error is given when updating a target which has a status which is
+ not
"Success".
"""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
target_id = vws_client.add_target(
name="x",
width=1,
@@ -403,11 +459,9 @@ def test_target_status_not_success(
assert not result.stdout
-def test_authentication_failure(mock_database: VuforiaDatabase) -> None:
- """
- An error is given when the secret key is incorrect.
- """
- runner = CliRunner(mix_stderr=False)
+def test_authentication_failure(mock_database: CloudDatabase) -> None:
+ """An error is given when the secret key is incorrect."""
+ runner = CliRunner()
commands = [
"list-targets",
"--server-access-key",
@@ -428,12 +482,13 @@ def test_authentication_failure(mock_database: VuforiaDatabase) -> None:
assert not result.stdout
-def test_request_time_too_skewed(mock_database: VuforiaDatabase) -> None:
+def test_request_time_too_skewed(mock_database: CloudDatabase) -> None:
"""
- An error is given when the request time is more than 5 minutes different
+ An error is given when the request time is more than 5 minutes
+ different
from the server time.
"""
- runner = CliRunner(mix_stderr=False)
+ runner = CliRunner()
vws_max_time_skew = 60 * 5
leeway = 10
time_difference_from_now = vws_max_time_skew + leeway
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 00000000..0b8382c5
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,2492 @@
+version = 1
+revision = 3
+requires-python = ">=3.14"
+resolution-markers = [
+ "python_full_version >= '3.15'",
+ "python_full_version < '3.15' and sys_platform != 'darwin'",
+ "python_full_version < '3.15' and sys_platform == 'darwin'",
+]
+
+[[package]]
+name = "accessible-pygments"
+version = "0.0.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" },
+]
+
+[[package]]
+name = "actionlint-py"
+version = "1.7.12.24"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/0b/3f29683dfbe94208fb5c3806806a6ef419972892e25c3c4f95198f68c978/actionlint_py-1.7.12.24.tar.gz", hash = "sha256:7571b0724fde79b2572b98b2b53792c470249d4db29951b57fc49b9cd3eaf11e", size = 12071, upload-time = "2026-03-31T06:21:35.015Z" }
+
+[[package]]
+name = "alabaster"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.12.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
+]
+
+[[package]]
+name = "ast-serialize"
+version = "0.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" },
+ { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" },
+ { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" },
+ { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" },
+ { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" },
+ { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" },
+ { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" },
+ { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" },
+ { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" },
+ { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" },
+ { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" },
+ { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" },
+ { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" },
+]
+
+[[package]]
+name = "astroid"
+version = "4.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/ca/c17d0f83016532a1ad87d1de96837164c99d47a3b6bbba28bd597c25b37a/astroid-4.0.3.tar.gz", hash = "sha256:08d1de40d251cc3dc4a7a12726721d475ac189e4e583d596ece7422bc176bda3", size = 406224, upload-time = "2026-01-03T22:14:26.096Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/66/686ac4fc6ef48f5bacde625adac698f41d5316a9753c2b20bb0931c9d4e2/astroid-4.0.3-py3-none-any.whl", hash = "sha256:864a0a34af1bd70e1049ba1e61cee843a7252c826d97825fcee9b2fcbd9e1b14", size = 276443, upload-time = "2026-01-03T22:14:24.412Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "25.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
+]
+
+[[package]]
+name = "babel"
+version = "2.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" },
+]
+
+[[package]]
+name = "beartype"
+version = "0.22.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" },
+]
+
+[[package]]
+name = "beautifulsoup4"
+version = "4.14.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
+]
+
+[[package]]
+name = "blinker"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
+]
+
+[[package]]
+name = "build"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "(python_full_version >= '3.15' and os_name == 'nt') or (os_name == 'nt' and sys_platform != 'darwin')" },
+ { name = "packaging" },
+ { name = "pyproject-hooks" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/18/94eaffda7b329535d91f00fe605ab1f1e5cd68b2074d03f255c7d250687d/build-1.4.0.tar.gz", hash = "sha256:f1b91b925aa322be454f8330c6fb48b465da993d1e7e7e6fa35027ec49f3c936", size = 50054, upload-time = "2026-01-08T16:41:47.696Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl", hash = "sha256:6a07c1b8eb6f2b311b96fcbdbce5dab5fe637ffda0fd83c9cac622e927501596", size = 24141, upload-time = "2026-01-08T16:41:46.453Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
+ { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
+ { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
+ { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
+ { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
+ { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
+ { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
+ { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
+]
+
+[[package]]
+name = "check-manifest"
+version = "0.51"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "build" },
+ { name = "setuptools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/16/e3/8ce797dfdf12d447683490107c4dcd97bb2535d7a2b031bf3f3e4c441c3d/check_manifest-0.51.tar.gz", hash = "sha256:9801c7637675755a563f33e3c48ee59a59b37a7677297c05c910c16c5b9b6d67", size = 36302, upload-time = "2025-10-15T11:15:48.007Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/c1/df01ef6ba1c8a2bfc201be45d0889b06f165008b240e8d1657aa665421a8/check_manifest-0.51-py3-none-any.whl", hash = "sha256:f5f35ed561012fc2115bb070e42a748ac2e034cf8904ab4dfaae893859085ca4", size = 20500, upload-time = "2025-10-15T11:15:46.058Z" },
+]
+
+[[package]]
+name = "check-wheel-contents"
+version = "0.6.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "click" },
+ { name = "packaging" },
+ { name = "pydantic" },
+ { name = "wheel-filename" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/95/37/2b9e0d38c2f668791ffe8b97711185c364d91c027e47f189c371c2348d18/check_wheel_contents-0.6.3.tar.gz", hash = "sha256:10e6939e2fe4e6ce1edf2ff6ec6157808677e80782e78021ae139dd88473a442", size = 586023, upload-time = "2025-08-02T14:01:45.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/be/05/f39fde9f31ef80b285ef5822fad4ddabf73fec62a1f02c5beb4b2f328972/check_wheel_contents-0.6.3-py3-none-any.whl", hash = "sha256:5ae39c8c434b972f0740d04610759168590713175aab584b012b1b84f6771874", size = 27541, upload-time = "2025-08-02T14:01:43.968Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
+]
+
+[[package]]
+name = "click-compose"
+version = "2025.10.27.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beartype" },
+ { name = "click" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/45/9f/7b380e5318643348e256ec31df1362b74dfa12733f76b1a97e1171ba74fe/click_compose-2025.10.27.3.tar.gz", hash = "sha256:6d3326a13b690ac7a0f0e99de785aa78ea81d130ba02d609e6367a7af23477a5", size = 18056, upload-time = "2025-10-27T11:49:45.228Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/45/3a/411c2ad30f87b2e874a4a4d1578dc9fe11a6ea8f139b4e1f7ff291a934ca/click_compose-2025.10.27.3-py2.py3-none-any.whl", hash = "sha256:6821fb769067e76d2b2e9c5d4d5e8d974002137322ab71cde65b10bf9c025834", size = 4731, upload-time = "2025-10-27T11:49:43.857Z" },
+]
+
+[[package]]
+name = "cloup"
+version = "3.0.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/46/cf/09a31f0f51b5c8ef2343baf37c35a5feb4f6dfdcbd0592a014baf837f2e4/cloup-3.0.8.tar.gz", hash = "sha256:f91c080a725196ddf74feabd6250266f466e97fc16dfe21a762cf6bc6beb3ecb", size = 229657, upload-time = "2025-08-05T02:25:02.83Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/45/0a/494a923f90cd97cdf4fb989cfd06ac0c6745f6dfb8adcef1b7f99d3c7834/cloup-3.0.8-py2.py3-none-any.whl", hash = "sha256:6fe9474dc44fa06f8870e9c797f005de1e3ef891ddc1a9612d9b58598a038323", size = 54647, upload-time = "2025-08-05T02:25:01.536Z" },
+]
+
+[[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 = "coverage"
+version = "7.13.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" },
+ { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" },
+ { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" },
+ { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" },
+ { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" },
+ { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" },
+ { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" },
+ { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" },
+]
+
+[[package]]
+name = "deptry"
+version = "0.25.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "packaging" },
+ { name = "requirements-parser" },
+ { name = "tomli", marker = "python_full_version < '3.15'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b8/b2/50ccc99362ae7757342978b7ecb3b98e47fade721fd617d74db1948ec3a1/deptry-0.25.1.tar.gz", hash = "sha256:45c8cd982c85cd4faae573ddff6920de7eec735336db6973f26a765ae7950f7d", size = 509748, upload-time = "2026-03-18T23:22:18.139Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/1d/b538dc635e873b25360d761cfe1fa0ccd7d6c69b698047e552f33401e60d/deptry-0.25.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a4dd1148db24a1ddacfa8b840836c6019c2f864fcb7579dd089fd217606338c8", size = 1850319, upload-time = "2026-03-18T23:22:15.65Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/a9/511477a8f0ae4f6021d68a80bdca77e7ffb0722008dc24ee5d9ef49f5c88/deptry-0.25.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c67c666d916ef12013c0772e40d78be0f21577a495d8d99ec5fcb18c332d393d", size = 1759259, upload-time = "2026-03-18T23:22:30.853Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/4b/c9f0bdda410912a6df79a789cb118fa29acae02a397794ead3c84adcda5c/deptry-0.25.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58d39279828dbf4efc1abb40bf50a71b21499c36759bed5a8d8a3c0e3149b091", size = 1872012, upload-time = "2026-03-18T23:22:19.145Z" },
+ { url = "https://files.pythonhosted.org/packages/72/9c/6f6f9125bac74b5d5d2af89536cbdb3fa159b6466aa097b74e7e85e8e030/deptry-0.25.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14bfcc28b4326ed8c6abb30691b19077d4ef8613cfba6c37ef5b1f471775bf6f", size = 1926575, upload-time = "2026-03-18T23:22:11.269Z" },
+ { url = "https://files.pythonhosted.org/packages/52/48/2a5e705a7f898295966ade67bd1223e2af96da433e25b39f6b9483ba2c7b/deptry-0.25.1-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:555f5f9a487899ec9bf301eecba1745e14d212c4b354f4d3a5fd691e907366d3", size = 2050816, upload-time = "2026-03-18T23:22:27.439Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/c6/50f189a894e1f3bf21266299112c8a06cb731838976e1b9a9cadd0b4a86e/deptry-0.25.1-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:18d21b3545ab2bfec53f3f45c6f5f201d55f713323327f8d12674505469ae6b7", size = 2145416, upload-time = "2026-03-18T23:22:24.682Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/6a/3f82f7a06217778282bc4456af1b4ffb3bc4b2c8e7891d00e8323f9ad0b8/deptry-0.25.1-cp310-abi3-win_amd64.whl", hash = "sha256:b59a560cb7dffb21832a98bb80d33d614cfb5630ea36ce21833eabf4eae3df99", size = 1718489, upload-time = "2026-03-18T23:22:28.589Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/7f/cd6b3ac8cf95f2f1c5c7a74ff6452e9098af89a9b56607381f677880641e/deptry-0.25.1-cp310-abi3-win_arm64.whl", hash = "sha256:6efffd8116fb9d2c45a251382ce4ce1c38dbb17179f581ec9231ed5390f7fc12", size = 1647020, upload-time = "2026-03-18T23:22:23.311Z" },
+]
+
+[[package]]
+name = "dill"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
+]
+
+[[package]]
+name = "distro"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
+]
+
+[[package]]
+name = "doc8"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "docutils" },
+ { name = "pygments" },
+ { name = "restructuredtext-lint" },
+ { name = "stevedore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/92/91/88bb55225046a2ee9c2243d47346c78d2ed861c769168f451568625ad670/doc8-2.0.0.tar.gz", hash = "sha256:1267ad32758971fbcf991442417a3935c7bc9e52550e73622e0e56ba55ea1d40", size = 28436, upload-time = "2025-06-13T13:08:53.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a2/e9/90b7d243364d3dce38c8c2a1b8c103d7a8d1383c2b24c735fae0eee038dd/doc8-2.0.0-py3-none-any.whl", hash = "sha256:9862710027f793c25f9b1899150660e4bf1d4c9a6738742e71f32011e2e3f590", size = 25861, upload-time = "2025-06-13T13:08:51.839Z" },
+]
+
+[[package]]
+name = "doccmd"
+version = "2026.7.19"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beartype" },
+ { name = "charset-normalizer" },
+ { name = "click" },
+ { name = "click-compose" },
+ { name = "cloup" },
+ { name = "dulwich" },
+ { name = "pygments" },
+ { name = "sybil" },
+ { name = "sybil-extras" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/1a1c30b66d9dd37d1d12e248943a8723da79b5c7d945bba5c0e469252433/doccmd-2026.7.19.tar.gz", hash = "sha256:1bb47f9ba5a3aaa907a5b52cd42f62d718689eccef68b94740127f7b758ce573", size = 196169, upload-time = "2026-07-19T13:56:40.913Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ff/ef/c97a0d16c9c2f0870811e9b03548f2aa3fc3e1e39fe80b768fa8458d6d8a/doccmd-2026.7.19-py3-none-any.whl", hash = "sha256:32d1bd7dfd87ebe28b7cf7637413012b2909514e7da3400de5dcc1cb18da1235", size = 24701, upload-time = "2026-07-19T13:56:39.117Z" },
+]
+
+[[package]]
+name = "docutils"
+version = "0.21.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" },
+]
+
+[[package]]
+name = "dom-toml"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "domdf-python-tools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ad/f6/edebb9290d3889d8ed460338dff752ab3764ec0727d81399e9328f38ac36/dom_toml-2.2.0.tar.gz", hash = "sha256:098d01b666bad39aa3d8cd916eea1c73c431a69e621511d3119c001604f27d9f", size = 12697, upload-time = "2026-01-02T21:19:05.839Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f9/d1/1a4664c9f615c5e6a56a2fc59e5f5bc1e89408af81955939e5b0eab8c02e/dom_toml-2.2.0-py3-none-any.whl", hash = "sha256:66455194679c2e77e5e3d97401f0f20e06a34b1506610d312eebef94fe198bc3", size = 17033, upload-time = "2026-01-02T21:19:04.943Z" },
+]
+
+[[package]]
+name = "domdf-python-tools"
+version = "3.10.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "natsort" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/36/8b/ab2d8a292bba8fe3135cacc8bfd3576710a14b8f2d0a8cde19130d5c9d21/domdf_python_tools-3.10.0.tar.gz", hash = "sha256:2ae308d2f4f1e9145f5f4ba57f840fbfd1c2983ee26e4824347789649d3ae298", size = 100458, upload-time = "2025-02-12T17:34:05.747Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5b/11/208f72084084d3f6a2ed5ebfdfc846692c3f7ad6dce65e400194924f7eed/domdf_python_tools-3.10.0-py3-none-any.whl", hash = "sha256:5e71c1be71bbcc1f881d690c8984b60e64298ec256903b3147f068bc33090c36", size = 126860, upload-time = "2025-02-12T17:34:04.093Z" },
+]
+
+[[package]]
+name = "dulwich"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ee/df/4178b6465e118e6e74fd78774b451953dd53c09fdec18f2c4b3319dd0485/dulwich-1.0.0.tar.gz", hash = "sha256:3d07104735525f22bfec35514ac611cf328c89b7acb059316a4f6e583c8f09bc", size = 1135862, upload-time = "2026-01-17T23:44:16.357Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/65/e18082b56f9bed8e07f0632fbf3b9fd29d31d66dc3329359fe6577c98741/dulwich-1.0.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1bd4c872b617edc20e0b726479ec25705a0107a4bd16f83cb6cd108d86950634", size = 1435705, upload-time = "2026-01-17T23:44:10.991Z" },
+ { url = "https://files.pythonhosted.org/packages/27/61/4974b3f593c3c1f7cf3656ca184246261632b81a27827ca453956d7b817e/dulwich-1.0.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc791771859258ffbe34a6579ece30684a535ae0341fd2f5adfa5f64cc885c13", size = 1435697, upload-time = "2026-01-17T23:44:12.708Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/99/4543953d2f7c1a940c1373362a70d253b85860be64b4ef8885bf8bfb340b/dulwich-1.0.0-py3-none-any.whl", hash = "sha256:221be803b71b060c928e9faae4ab3e259ff5beac6e0c251ba3c176b51b5c2ffb", size = 647950, upload-time = "2026-01-17T23:44:14.449Z" },
+]
+
+[[package]]
+name = "filelock"
+version = "3.20.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
+]
+
+[[package]]
+name = "flask"
+version = "3.1.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "blinker" },
+ { name = "click" },
+ { name = "itsdangerous" },
+ { name = "jinja2" },
+ { name = "markupsafe" },
+ { name = "werkzeug" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
+]
+
+[[package]]
+name = "freezegun"
+version = "1.5.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "python-dateutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
+]
+
+[[package]]
+name = "fsspec"
+version = "2026.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d5/7d/5df2650c57d47c57232af5ef4b4fdbff182070421e405e0d62c6cdbfaa87/fsspec-2026.1.0.tar.gz", hash = "sha256:e987cb0496a0d81bba3a9d1cee62922fb395e7d4c3b575e57f547953334fe07b", size = 310496, upload-time = "2026-01-09T15:21:35.562Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" },
+]
+
+[[package]]
+name = "furo"
+version = "2025.12.19"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "accessible-pygments" },
+ { name = "beautifulsoup4" },
+ { name = "pygments" },
+ { name = "sphinx" },
+ { name = "sphinx-basic-ng" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz", hash = "sha256:188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7", size = 1661473, upload-time = "2025-12-19T17:34:40.889Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" },
+]
+
+[[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 = "hadolint-bin"
+version = "2.15.1"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/2d/61dd58ec15986fa29204564438d82bdc46eec885e9f3049a3936ad3b571a/hadolint_bin-2.15.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:df1696d766373cac8f2fe0938230d65efb9348557816f402868e0769de62916c", size = 4897923, upload-time = "2026-08-03T17:43:53.034Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/f5/6ee242b698bc38b02730c718f6e12d898651f44c96ff574ddef606c27644/hadolint_bin-2.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2dbbf293728f1e4232bb3f8a8165009ce27f2f70957381eeb5e501d08679769c", size = 21349339, upload-time = "2026-08-03T17:43:48.839Z" },
+ { url = "https://files.pythonhosted.org/packages/35/39/61cfdc0ccd2e43865249a25c02db8e54115ba97eae178b619ce1af527926/hadolint_bin-2.15.1-py3-none-manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:9d810140051c6068d002202a420e5705ffa9fff42e7fc0f54e67f90e6845723d", size = 13062348, upload-time = "2026-08-03T17:43:50.979Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/4f/2d98b50d966b32e4e6f71ac429bd90bd1c2390ce4cd7dcc1056df9c233a2/hadolint_bin-2.15.1-py3-none-manylinux2014_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:bed607e611b0f7e4ef78e4bba9bdf39504d8234d3eadfe86d702f2bff14ac2d5", size = 12154446, upload-time = "2026-08-03T17:43:54.75Z" },
+]
+
+[[package]]
+name = "homebrew-pypi-poet"
+version = "0.10.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jinja2" },
+ { name = "setuptools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f7/d9/4b525af3be6ac0a0a962e101b7771db6511d9e96369ded2765406233f9ff/homebrew-pypi-poet-0.10.0.tar.gz", hash = "sha256:e09e997e35a98f66445f9a39ccb33d6d93c5cd090302a59f231707eac0bf378e", size = 5953, upload-time = "2018-02-23T06:22:55.794Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/85/998232eae0b5c6798c7140ef37d2c1be02ea06cd38dd80169b3abd63b600/homebrew_pypi_poet-0.10.0-py2.py3-none-any.whl", hash = "sha256:65824f97aea0e713c4ac18aa2ef4477aca69426554eac842eeaaddf97df3fc47", size = 7813, upload-time = "2018-02-23T06:22:54.858Z" },
+]
+
+[[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.15"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
+]
+
+[[package]]
+name = "imagesize"
+version = "1.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "interrogate"
+version = "1.7.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "click" },
+ { name = "colorama" },
+ { name = "py" },
+ { name = "tabulate" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8b/22/74f7fcc96280eea46cf2bcbfa1354ac31de0e60a4be6f7966f12cef20893/interrogate-1.7.0.tar.gz", hash = "sha256:a320d6ec644dfd887cc58247a345054fc4d9f981100c45184470068f4b3719b0", size = 159636, upload-time = "2024-04-07T22:30:46.217Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl", hash = "sha256:b13ff4dd8403369670e2efe684066de9fcb868ad9d7f2b4095d8112142dc9d12", size = 46982, upload-time = "2024-04-07T22:30:44.277Z" },
+]
+
+[[package]]
+name = "isort"
+version = "7.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" },
+]
+
+[[package]]
+name = "itsdangerous"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
+]
+
+[[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 = "librt"
+version = "0.13.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" },
+ { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" },
+ { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" },
+ { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" },
+ { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" },
+ { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" },
+ { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" },
+ { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" },
+ { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" },
+ { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" },
+]
+
+[[package]]
+name = "loguru"
+version = "0.7.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "win32-setctime", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
+]
+
+[[package]]
+name = "maison"
+version = "2.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "loguru" },
+ { name = "platformdirs" },
+ { name = "typer" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/24/45/7cb1d08b6b5674c381b6e0232d35f417a1eba8bb66cdc18edff2b9c80b68/maison-2.0.2.tar.gz", hash = "sha256:476f2bf414a20f5abf5a9856bd4db78b5a33c695654a0fc49c3c4abed78c2efc", size = 16012, upload-time = "2025-10-09T07:52:33.27Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5b/8f/3f0895a18cad5afd61c16ac38d35a2466f0cac8ae5c28f1a67f7a81bcdec/maison-2.0.2-py3-none-any.whl", hash = "sha256:835de804aa8063795b48c4fe2b4918106cfda4e5df515e8784ec9fa64cd28191", size = 13464, upload-time = "2025-10-09T07:52:31.987Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
+]
+
+[[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/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 = "mccabe"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
+]
+
+[[package]]
+name = "mdit-py-plugins"
+version = "0.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+]
+
+[[package]]
+name = "mpmath"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
+]
+
+[[package]]
+name = "mypy"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ast-serialize" },
+ { name = "librt", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "mypy-extensions" },
+ { name = "pathspec" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" },
+ { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" },
+ { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" },
+ { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" },
+ { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" },
+ { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" },
+ { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" },
+]
+
+[package.optional-dependencies]
+faster-cache = [
+ { name = "orjson" },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+]
+
+[[package]]
+name = "mypy-strict-kwargs"
+version = "2026.7.19.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mypy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/67/ff/c7100891e45af1ce05db4644ea4a6e3f53aa6a019ab9defecd5fde9e7d69/mypy_strict_kwargs-2026.7.19.1.tar.gz", hash = "sha256:735a1956937365daaea84a6a3a556fde7255575e6fabc5336e499bb890abe44f", size = 30368, upload-time = "2026-07-19T11:59:40.177Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/50/8a/0f55537fcfe8d358f681059be0ea158df2066d629215cd9090eae4937e17/mypy_strict_kwargs-2026.7.19.1-py3-none-any.whl", hash = "sha256:80c56a7792bb4f0880f7686a8f576022514b219ff8c7c1a8c6e5096c0d64cae2", size = 14064, upload-time = "2026-07-19T11:59:39.009Z" },
+]
+
+[[package]]
+name = "myst-parser"
+version = "5.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "docutils" },
+ { name = "jinja2" },
+ { name = "markdown-it-py" },
+ { name = "mdit-py-plugins" },
+ { name = "pyyaml" },
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" },
+]
+
+[[package]]
+name = "natsort"
+version = "8.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575, upload-time = "2023-06-20T04:17:19.925Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" },
+]
+
+[[package]]
+name = "networkx"
+version = "3.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
+]
+
+[[package]]
+name = "no-defaults"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8a/dd/37d0ae30b5d74d039d9e9c1b0e7dd5ebee509fe848314825505f2798a444/no_defaults-2.1.0.tar.gz", hash = "sha256:d98d78c7dace794bed068fbbada2ee7e94705141aa17c2a8d7047bacb3da2562", size = 96084, upload-time = "2026-08-07T12:35:34.71Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6c/5e/2efb07ac90e5db3623a6ee6fbacbd3635d4d63c359cfc94ed39cca685649/no_defaults-2.1.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:94b884e31e7727f6a476b6dff0b655fd37b65f6f209ddccba917888260050ca0", size = 2141080, upload-time = "2026-08-07T12:35:30.314Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/be/33eacae5d5b420df33a9b5a716eae4a8c3528a795e6435cbcf7fe00d0041/no_defaults-2.1.0-py3-none-manylinux_2_34_x86_64.whl", hash = "sha256:bed135d188635d786cca6dde75982d507f8e2b46fbc17f047c794af2153186d3", size = 2328857, upload-time = "2026-08-07T12:35:31.861Z" },
+ { url = "https://files.pythonhosted.org/packages/35/a2/8cdeb0081d019275c86a1e120aa744b13de6ca4260162b6c41215fa0e007/no_defaults-2.1.0-py3-none-win_amd64.whl", hash = "sha256:d14855c701f5e1310e304e3011cbc5836fa19e7494125e6673dd14701fc49b7e", size = 2052526, upload-time = "2026-08-07T12:35:33.435Z" },
+]
+
+[[package]]
+name = "nodeenv"
+version = "1.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.5.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" },
+ { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" },
+ { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" },
+ { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" },
+ { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" },
+ { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" },
+ { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" },
+ { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" },
+ { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" },
+ { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" },
+ { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" },
+ { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" },
+ { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" },
+ { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" },
+ { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" },
+ { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" },
+ { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" },
+ { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" },
+ { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" },
+ { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" },
+ { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" },
+]
+
+[[package]]
+name = "opencv-contrib-python-headless"
+version = "5.0.0.93"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fd/85/da534b90d99a040fbdccc6eb2c3d1c6c6d0e1f7d54732f4ce7d2e1726b29/opencv_contrib_python_headless-5.0.0.93.tar.gz", hash = "sha256:6a8c34de905f59b038f6ab0475e73fb0a0f345c3582ec3aeaeb1c0912e80cddb", size = 154148527, upload-time = "2026-07-02T06:59:00.233Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/50/b9/2a3a9e23d7894f816792f93f2e73c017765a29e854ba343cb260da400a7e/opencv_contrib_python_headless-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:bf1e2b3c502b4fbe7b06e307bd15c4e7bea7da8895de19c189db01d53062f997", size = 55653233, upload-time = "2026-07-02T05:50:39.358Z" },
+ { url = "https://files.pythonhosted.org/packages/63/72/1b7d64b03e54f775035bda1dc363dfde89e9301a9bdf82949b239ddf80ad/opencv_contrib_python_headless-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:68eb7b803f68998552a83b5f3c69bfb1c7d6c114dc30f828657bddc7ca68573a", size = 42828135, upload-time = "2026-07-02T05:51:44.374Z" },
+ { url = "https://files.pythonhosted.org/packages/61/14/f12a54f7e5e8783adf905bb863ef2257e41d73d44793851d318e35ee8121/opencv_contrib_python_headless-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c035e12b6078b3351ad577e90a4ff4ead74155fa5470eb8df8f5b54a8ce7727f", size = 43800290, upload-time = "2026-07-02T06:51:24.76Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/c0/a51fd47d4f82cbc0bb36d082c87f6da25e4fb2a1cdec307a72f063cf3d55/opencv_contrib_python_headless-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2ff190c4ebfa2839bdcaff1810061bc82f9ce5cf37e28953fb57fa5ed519b9d1", size = 64676895, upload-time = "2026-07-02T06:52:01.148Z" },
+ { url = "https://files.pythonhosted.org/packages/51/78/11f0704e94ebf92f748c789cf5d430d4459ae3649263c712d81772cc4360/opencv_contrib_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01331ae1a65a21d81310b5584900f73c3012ab4bdcf6aa26085c1870eea45da8", size = 44443761, upload-time = "2026-07-02T06:52:22.433Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/87/3a803f04a28d0161fd9f5e085595507d7008d760ba95be6afa5760a41bbf/opencv_contrib_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:63ddadd47e36fbc903d5ac141d5c90bd305a8044188dd4936a73de80007c9e6c", size = 69475842, upload-time = "2026-07-02T06:52:49.523Z" },
+ { url = "https://files.pythonhosted.org/packages/30/9f/11dd169d7a037c35b1fec0eb766c07bc62cde6428f2762df233111a43204/opencv_contrib_python_headless-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:ca47e569680df6eb7317b54f463bc766832d3d87854988168673bb0c52d6d9d4", size = 44203828, upload-time = "2026-07-02T05:50:20.943Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/f3/6d7a7e512d88df0358c09c36cfac1a9050c50df547c64d32dc5f82fade49/opencv_contrib_python_headless-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:72605e3ae78f66592705b1abe62e60f57dd9050787fdd99711b85a02760d5259", size = 53654741, upload-time = "2026-07-02T05:50:17.153Z" },
+]
+
+[[package]]
+name = "orjson"
+version = "3.11.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/70/a3/4e09c61a5f0c521cba0bb433639610ae037437669f1a4cbc93799e731d78/orjson-3.11.6.tar.gz", hash = "sha256:0a54c72259f35299fd033042367df781c2f66d10252955ca1efb7db309b954cb", size = 6175856, upload-time = "2026-01-29T15:13:07.942Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/31/9f/46ca908abaeeec7560638ff20276ab327b980d73b3cc2f5b205b4a1c60b3/orjson-3.11.6-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6026db2692041d2a23fe2545606df591687787825ad5821971ef0974f2c47630", size = 249823, upload-time = "2026-01-29T15:12:43.332Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/78/ca478089818d18c9cd04f79c43f74ddd031b63c70fa2a946eb5e85414623/orjson-3.11.6-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:132b0ab2e20c73afa85cf142e547511feb3d2f5b7943468984658f3952b467d4", size = 134328, upload-time = "2026-01-29T15:12:45.171Z" },
+ { url = "https://files.pythonhosted.org/packages/39/5e/cbb9d830ed4e47f4375ad8eef8e4fff1bf1328437732c3809054fc4e80be/orjson-3.11.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b376fb05f20a96ec117d47987dd3b39265c635725bda40661b4c5b73b77b5fde", size = 137651, upload-time = "2026-01-29T15:12:46.602Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/3a/35df6558c5bc3a65ce0961aefee7f8364e59af78749fc796ea255bfa0cf5/orjson-3.11.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:954dae4e080574672a1dfcf2a840eddef0f27bd89b0e94903dd0824e9c1db060", size = 134596, upload-time = "2026-01-29T15:12:47.95Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/8e/3d32dd7b7f26a19cc4512d6ed0ae3429567c71feef720fe699ff43c5bc9e/orjson-3.11.6-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe515bb89d59e1e4b48637a964f480b35c0a2676de24e65e55310f6016cca7ce", size = 140923, upload-time = "2026-01-29T15:12:49.333Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/9c/1efbf5c99b3304f25d6f0d493a8d1492ee98693637c10ce65d57be839d7b/orjson-3.11.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:380f9709c275917af28feb086813923251e11ee10687257cd7f1ea188bcd4485", size = 144068, upload-time = "2026-01-29T15:12:50.927Z" },
+ { url = "https://files.pythonhosted.org/packages/82/83/0d19eeb5be797de217303bbb55dde58dba26f996ed905d301d98fd2d4637/orjson-3.11.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8173e0d3f6081e7034c51cf984036d02f6bab2a2126de5a759d79f8e5a140e7", size = 142493, upload-time = "2026-01-29T15:12:52.432Z" },
+ { url = "https://files.pythonhosted.org/packages/32/a7/573fec3df4dc8fc259b7770dc6c0656f91adce6e19330c78d23f87945d1e/orjson-3.11.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dddf9ba706294906c56ef5150a958317b09aa3a8a48df1c52ccf22ec1907eac", size = 145616, upload-time = "2026-01-29T15:12:53.903Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/0e/23551b16f21690f7fd5122e3cf40fdca5d77052a434d0071990f97f5fe2f/orjson-3.11.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cbae5c34588dc79938dffb0b6fbe8c531f4dc8a6ad7f39759a9eb5d2da405ef2", size = 146951, upload-time = "2026-01-29T15:12:55.698Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/63/5e6c8f39805c39123a18e412434ea364349ee0012548d08aa586e2bd6aa9/orjson-3.11.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f75c318640acbddc419733b57f8a07515e587a939d8f54363654041fd1f4e465", size = 421024, upload-time = "2026-01-29T15:12:57.434Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/4d/724975cf0087f6550bd01fd62203418afc0ea33fd099aed318c5bcc52df8/orjson-3.11.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e0ab8d13aa2a3e98b4a43487c9205b2c92c38c054b4237777484d503357c8437", size = 155774, upload-time = "2026-01-29T15:12:59.397Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/a3/f4c4e3f46b55db29e0a5f20493b924fc791092d9a03ff2068c9fe6c1002f/orjson-3.11.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f884c7fb1020d44612bd7ac0db0babba0e2f78b68d9a650c7959bf99c783773f", size = 147393, upload-time = "2026-01-29T15:13:00.769Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/86/6f5529dd27230966171ee126cecb237ed08e9f05f6102bfaf63e5b32277d/orjson-3.11.6-cp314-cp314-win32.whl", hash = "sha256:8d1035d1b25732ec9f971e833a3e299d2b1a330236f75e6fd945ad982c76aaf3", size = 139760, upload-time = "2026-01-29T15:13:02.173Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/b5/91ae7037b2894a6b5002fb33f4fbccec98424a928469835c3837fbb22a9b/orjson-3.11.6-cp314-cp314-win_amd64.whl", hash = "sha256:931607a8865d21682bb72de54231655c86df1870502d2962dbfd12c82890d077", size = 136633, upload-time = "2026-01-29T15:13:04.267Z" },
+ { url = "https://files.pythonhosted.org/packages/55/74/f473a3ec7a0a7ebc825ca8e3c86763f7d039f379860c81ba12dcdd456547/orjson-3.11.6-cp314-cp314-win_arm64.whl", hash = "sha256:fe71f6b283f4f1832204ab8235ce07adad145052614f77c876fcf0dac97bc06f", size = 135168, upload-time = "2026-01-29T15:13:05.932Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
+]
+
+[[package]]
+name = "pathspec"
+version = "1.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" },
+]
+
+[[package]]
+name = "pillow"
+version = "12.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
+ { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
+ { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
+ { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
+ { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
+ { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
+ { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
+ { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
+ { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
+ { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
+ { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
+ { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
+ { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
+ { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
+ { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
+ { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
+ { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "polib"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/10/9a/79b1067d27e38ddf84fe7da6ec516f1743f31f752c6122193e7bce38bdbf/polib-1.2.0.tar.gz", hash = "sha256:f3ef94aefed6e183e342a8a269ae1fc4742ba193186ad76f175938621dbfc26b", size = 161658, upload-time = "2023-02-23T17:53:56.873Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6b/99/45bb1f9926efe370c6dbe324741c749658e44cb060124f28dad201202274/polib-1.2.0-py2.py3-none-any.whl", hash = "sha256:1c77ee1b81feb31df9bca258cbc58db1bbb32d10214b173882452c73af06d62d", size = 20634, upload-time = "2023-02-23T17:53:59.919Z" },
+]
+
+[[package]]
+name = "prek"
+version = "0.4.13"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d4/79/19f47eeb4d6092d36f94f47a056e7ae7a421d60220c772e8513483521b63/prek-0.4.13.tar.gz", hash = "sha256:9bf3dce400ef38a281836e4fe6429aa5f1690848be77cd97cb53c93769db4681", size = 533200, upload-time = "2026-08-10T08:54:15.477Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/12/661dc1c63c322000580dffa8503d28d633cb5d4c3181e662cbb86eded12e/prek-0.4.13-py3-none-linux_armv6l.whl", hash = "sha256:6a313f5f041b2fcbd33bceb6b6e11ee9b8621c8c6ad8ef13787a6d90541b624d", size = 5801508, upload-time = "2026-08-10T08:53:52.18Z" },
+ { url = "https://files.pythonhosted.org/packages/86/4a/c50597a45d08b22e5704a5d4d5c03269a581b46cf1f060861f9ba96cdade/prek-0.4.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:40436cd7247d2a2fc036ef07d7efcd828acf1aab9d648a8a3f21475e3ad3f789", size = 6141612, upload-time = "2026-08-10T08:53:53.86Z" },
+ { url = "https://files.pythonhosted.org/packages/82/93/abc084bbd76bb6c34efbf7db441392f17b7bfc50a54c4d53d3e37c7ad6e0/prek-0.4.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:019a33b477b7b949fb6dcd6cb33ed494c4a28117e53c370c637ec0aba60211aa", size = 5625418, upload-time = "2026-08-10T08:53:55.39Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/4d/a310ff9adcb4b822d935eb55f0f5cdcfe4c18a16610bc5e220a11215dc38/prek-0.4.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:5a2851b6e60912e73be1bf2cf61fab5b314add15ba7bcf230f860282bdbaf16b", size = 5942132, upload-time = "2026-08-10T08:53:56.789Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/d8/811230ff285000abdc0fb98b56b9ef2d23b2dc530cf2da86fe1665f844ba/prek-0.4.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9ba9b94bd3f47b5f94a4ae504c44f9529cb3258ebaf577a5a6070ea0c6a853d6", size = 5710181, upload-time = "2026-08-10T08:53:58.278Z" },
+ { url = "https://files.pythonhosted.org/packages/74/84/01f7163cc4267daba6b01cfac726327c6d848d2bb7349bd2fd877d88f744/prek-0.4.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33fc9e0d435cb9e650ec108f840febe7a94fd266c77149860091cead212dc82d", size = 6157705, upload-time = "2026-08-10T08:53:59.586Z" },
+ { url = "https://files.pythonhosted.org/packages/30/e3/061a0e9ebd7064edf70bb783afa575d045ac0cc9035c2143b76401ebcaf5/prek-0.4.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91066d8978eab83c111e7c34ee3046a003819d2df15ef7dc2bddeb83dce62bc0", size = 6898016, upload-time = "2026-08-10T08:54:00.927Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/68/5e8e2b9ff6a30b46bf35ca3b50e4d164c26406d9e7457d5b4633c6e1fbc0/prek-0.4.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85646fcc940f30bd946d63b6bbd1000d946994c88e154c5bdde7773a2c358dcf", size = 6365056, upload-time = "2026-08-10T08:54:02.704Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/23/afe543b69fb7f35016645eb4b766191e814cbcfa5545d695a2a7dd9b712e/prek-0.4.13-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:961859c3ddddb8e10367afcf93742da36fa213eda080f168fc1dcbe33e0b004d", size = 5952174, upload-time = "2026-08-10T08:54:04.079Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/6e/9e651ce51aa0b03244277f5e0660cf1b946a270cb62635a8a100389a67a2/prek-0.4.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ccf4fcbfc686ad6b589f2908ed6012a315091c45163e1f4420648b9669651a9e", size = 5761875, upload-time = "2026-08-10T08:54:05.378Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/64/e70e18734d93df272476d2f1b30d92c71d41ee7141070f1dd13cfbb2eff8/prek-0.4.13-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:eb4b7edfe00ca73e58d7e26fb871abddc5854e8d21067a202e43418fb7f98ef2", size = 5685365, upload-time = "2026-08-10T08:54:06.729Z" },
+ { url = "https://files.pythonhosted.org/packages/de/78/686fd5d3f12249368a0fdd8f7705e3ba540402a6157ef0b888e48734ff83/prek-0.4.13-py3-none-musllinux_1_1_i686.whl", hash = "sha256:c29449443f89da1647331742984b7046a55084759ad642373a9cc0a973494339", size = 5999013, upload-time = "2026-08-10T08:54:08.183Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/aa/93ffac2460b44f6182dbbb3194db976d18332edcb8208c4a796f8f9955f8/prek-0.4.13-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:7a04d5ac901819b115ecd0bf79cee091f0034323767abcc4a53626eeb96c3be0", size = 6486759, upload-time = "2026-08-10T08:54:09.775Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/3c/9f12c0d469ea345c249d5b0a027a1f2bf1ca05041d7785c34c455a9b605c/prek-0.4.13-py3-none-win32.whl", hash = "sha256:6d1bdcc1699ae18270f9bac9c4b4d29c6f1512b7a067e17ce5e220c30636f88c", size = 5515046, upload-time = "2026-08-10T08:54:11.456Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/3c/ef9ec67c560e60525d6a49b48a2a60434906f25ec2efa5e010fe2d42bbfa/prek-0.4.13-py3-none-win_amd64.whl", hash = "sha256:2d8fd796ed7944154fbee6d5a6d2490b9d4f14ce3626b1a0c9ca698455b25d9b", size = 5894683, upload-time = "2026-08-10T08:54:12.88Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/ce/8fe8fdf8154108a552d5578400da44144697ed11e5bd71d5a4980d7e202e/prek-0.4.13-py3-none-win_arm64.whl", hash = "sha256:65d6811b0220444bcdf539e157d7d5cb8edab01a5ed89534c70d73d675266ecb", size = 5650616, upload-time = "2026-08-10T08:54:14.21Z" },
+]
+
+[[package]]
+name = "py"
+version = "1.11.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/98/ff/fec109ceb715d2a6b4c4a85a61af3b40c723a961e8828319fbcb15b868dc/py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", size = 207796, upload-time = "2021-11-04T17:17:01.377Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", size = 98708, upload-time = "2021-11-04T17:17:00.152Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.12.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.41.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
+ { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
+ { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
+ { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
+ { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
+ { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
+ { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
+ { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
+ { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
+ { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
+ { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
+]
+
+[[package]]
+name = "pydantic-settings"
+version = "2.14.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" },
+]
+
+[[package]]
+name = "pydocstringformatter"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/c9/435887301c667ddcf1ed524ba82ff0998c280077f4987da6f3f72cb43778/pydocstringformatter-1.0.0.tar.gz", hash = "sha256:0c2bc5e200ff118feab96c204b1f69ddb604832e177b26b598939e0e6913dcd0", size = 29543, upload-time = "2026-07-04T16:39:24.544Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2d/ee/8acba0b2f928bd046ce06eef9c8f0450a3c25711f17b8362e6ed9b473da7/pydocstringformatter-1.0.0-py3-none-any.whl", hash = "sha256:3f625f91798b14ee7c4fc8e1628c2d040390e32efa76f05ef14838e9bc2d1724", size = 30188, upload-time = "2026-07-04T16:39:23.095Z" },
+]
+
+[[package]]
+name = "pyenchant"
+version = "3.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/36/ad/64925c937e41be75c7067c85757b3d45b148e9111187b37693269f583156/pyenchant-3.3.0.tar.gz", hash = "sha256:825288246b5debc9436f91967650974ef0d5636458502619e322c476f1283891", size = 60696, upload-time = "2025-09-14T16:23:12.113Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/b0/35926bad6885fb7bc24aa7e1b45e6d86540c6c57ee4abc4fed1ef58d4ec0/pyenchant-3.3.0-py3-none-any.whl", hash = "sha256:3da00b1d01314d85aac733bb997415d7a3e875666dc81735ddcf320aa36b7a70", size = 58363, upload-time = "2025-09-14T16:23:04.297Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/7f/1d7b8ad86c2a841d940df7b965fa727e052b95d539e4c563da685c25d0d2/pyenchant-3.3.0-py3-none-win32.whl", hash = "sha256:1d55e075645a6edbb3c590fb42f9e02b4d455e4affe28a2227d5cb6d4868e626", size = 37787278, upload-time = "2025-09-14T16:23:06.629Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/ae/5624803b62ecb0a20248f0d28ed3f78c78746a032582a016d4b2890c7899/pyenchant-3.3.0-py3-none-win_amd64.whl", hash = "sha256:04a5bd0e022ebe2e8c6d9e498ec3d650602e264ec5486e9c6a1b7f99c9507c49", size = 37427576, upload-time = "2025-09-14T16:23:09.574Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pylint"
+version = "4.0.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "astroid" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "dill" },
+ { name = "isort" },
+ { name = "mccabe" },
+ { name = "platformdirs" },
+ { name = "tomlkit" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/de/92/98dace02f2d11b88160354c53944f77ea7327aa78bce1c75971e7aaa4347/pylint-4.0.7.tar.gz", hash = "sha256:9b2d1d15791c84b77a4fe2aafe8f0d9570717e2dea06d53b19c105cf60275a52", size = 1594770, upload-time = "2026-08-09T19:13:23.289Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/b0/3a8040e53df6c5c1e04b0e23ed53fdbeb64f333723a334d313fba2f581ce/pylint-4.0.7-py3-none-any.whl", hash = "sha256:be4a3111557a614411ed1fc89347ce4a8e1013a59e1f33d11485227a02e3304d", size = 539710, upload-time = "2026-08-09T19:13:21.228Z" },
+]
+
+[package.optional-dependencies]
+spelling = [
+ { name = "pyenchant" },
+]
+
+[[package]]
+name = "pyproject-fmt"
+version = "2.27.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/39/65b0c0342498ca594f1f678e9bb955bde32ba75ffabf8dff6c711d703109/pyproject_fmt-2.27.0.tar.gz", hash = "sha256:31f638e1d42a6689922d9c413d410a5f3f56e45c844830763321119e919cd45b", size = 300145, upload-time = "2026-08-03T22:54:59.243Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1f/41/2ef4c9e092a4c3e7c70311eb2cfd4125fac742734db708ab5c00cef62483/pyproject_fmt-2.27.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0a780b411258f5ee62b9c622c0c07f55053e64e0866cfede5648b99b7573ec84", size = 5344637, upload-time = "2026-08-03T22:54:07.039Z" },
+ { url = "https://files.pythonhosted.org/packages/69/56/4f88cfd3f4e83bd6ba16c0a9b0d9776efbeb4bd504fb661da5d9344790a6/pyproject_fmt-2.27.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:62acfbfe9dc150e542d0764aed71e901f6f2d818e3a9c07ee5176306348db146", size = 5102853, upload-time = "2026-08-03T22:54:08.96Z" },
+ { url = "https://files.pythonhosted.org/packages/82/93/84b226f18da04a3ef21308f8a310b859e96c44cbeb88723f0409d70eb411/pyproject_fmt-2.27.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:754f840e915861a594ab402c1c62e09c7a53692e5db57eb544a6731154c674ce", size = 5273151, upload-time = "2026-08-03T22:54:10.75Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/79/d6d47dc82e39a759819af2cde51adfefcef9cff32a421245f32b18393ecf/pyproject_fmt-2.27.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:97c803bd973d5c8a37a368a7c6d571ff09817570bc48b23b286ab3c55712c124", size = 5667236, upload-time = "2026-08-03T22:54:12.448Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/28/2e2c1a3a558be20b6fdf4c415fed97e5da32d3232ae90f84091a3b00a486/pyproject_fmt-2.27.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:dcdd93f1c1cab93b79ed6777ef729ac7961f3b1e8a7a0b7ff04b4b826a016b96", size = 5365689, upload-time = "2026-08-03T22:54:14.431Z" },
+ { url = "https://files.pythonhosted.org/packages/50/9f/b57e8ad891ea9f5fa6afec3c2d25d7d8cc8b8725cfd91381ff9b08baf8d4/pyproject_fmt-2.27.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d274ec865d4af811b874e85f23cfdc332ab05f2a66c5a6e6d10d3ff689462b5d", size = 5272980, upload-time = "2026-08-03T22:54:16.335Z" },
+ { url = "https://files.pythonhosted.org/packages/56/61/914c9c4957b2e944c84adf211d6a8f28554bcc97b553de170a81aca5e96e/pyproject_fmt-2.27.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a3fc9615a0b438c2cf9b7d233a9235b5889fd3cc9f7376e90418bce5f4f2acc8", size = 5842234, upload-time = "2026-08-03T22:54:18.219Z" },
+ { url = "https://files.pythonhosted.org/packages/90/f8/f16f7cc82eca680997cb36fb175a3a2a9246b47a1d90d718d59baf0f111b/pyproject_fmt-2.27.0-cp310-abi3-win_amd64.whl", hash = "sha256:3b44079ecf57addebfb06c8cbba5961d69eaea8e5dc5a21b781804c0c49a48b7", size = 5546145, upload-time = "2026-08-03T22:54:20.443Z" },
+ { url = "https://files.pythonhosted.org/packages/33/79/4d3fdfbdf4afe879dab75555af1387fca77f0f3397c36d79f9e3092d09d2/pyproject_fmt-2.27.0-cp310-abi3-win_arm64.whl", hash = "sha256:b226c744ab4d1a6918ecbccbcc2d73d0e7a3527d73c44dc8c5a3775c56dc0dd0", size = 5068780, upload-time = "2026-08-03T22:54:22.377Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/c8/b621c3cbc19eb81d2473aab02c09bd2418929f3d61d9f3c92f127f285ba4/pyproject_fmt-2.27.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ce05601d0d266587a8cd08af6e1cc19935e1dc3be7d6f83fa20537d6a242512d", size = 5344535, upload-time = "2026-08-03T22:54:24.127Z" },
+ { url = "https://files.pythonhosted.org/packages/23/c4/f7e51e7f59d586e0797de2f533029c71cd888025b33d51dbb3967ffd2f10/pyproject_fmt-2.27.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:97901311988fd3601df115af63cd285317ad60b4db79c4598efcbe9f8a7816c2", size = 5097006, upload-time = "2026-08-03T22:54:25.99Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/13/cd92d6bab4f1075425982e3e6c85bc300e320c9e8e53e96028386192ef73/pyproject_fmt-2.27.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:624a4aa2f4553697dd078f1c00edc8659a5e5ccfd07325611358332f0a5dcf13", size = 5267186, upload-time = "2026-08-03T22:54:27.869Z" },
+ { url = "https://files.pythonhosted.org/packages/76/99/948a3f766935723b17143ee23bdf15b7b35bc9248fb14cbd019ece93ce2a/pyproject_fmt-2.27.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cdec6b19d887bb27df0a06448e9d0a4c3366c4a0e5b6ad308262d379a904d337", size = 5662485, upload-time = "2026-08-03T22:54:29.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/30/7700d5c1a123ed76289b673f68f2f558faddeea4f60b2d58634acd91497c/pyproject_fmt-2.27.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:00833606878a5cf8e0d980e9175788a5b6d44a0d68d88d5723ec2befafddda06", size = 5266508, upload-time = "2026-08-03T22:54:32.135Z" },
+ { url = "https://files.pythonhosted.org/packages/31/46/4b177d5606d3632a8690bc8489ae34984e712e58aa52496c811f1706d671/pyproject_fmt-2.27.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48d5f4da35d77905bf2234f12b787fecb2247d0573793f31c075a1469333bf39", size = 5837669, upload-time = "2026-08-03T22:54:34.081Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/9d/c13f03014b2538a515caa83b6a41ef08b29f6ee6af7ac3cd75f0732384a6/pyproject_fmt-2.27.0-cp314-cp314t-win_amd64.whl", hash = "sha256:cc014e22b1e10d5f0d7b51faa281d310f04a646607ed4e620cf031bd5d7f57e8", size = 5543009, upload-time = "2026-08-03T22:54:36.049Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/21/341bc93ce40488a0e49bfc45e3328e11d4011ce655f2f45bd1f91f989199/pyproject_fmt-2.27.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3e104c0a28212af3e2b4228b04c83220c762ce4f0bd0ec7bb2f2230361a7f154", size = 5064853, upload-time = "2026-08-03T22:54:38.72Z" },
+ { url = "https://files.pythonhosted.org/packages/db/44/34cef5210591b288e0cc44138ecd4549ca93e933e11c26d4449129d34ae0/pyproject_fmt-2.27.0-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:f84843bfc074b8defa4376113a9d17b9d5f7ec10c5c5185662427409b4b9fea9", size = 5344052, upload-time = "2026-08-03T22:54:40.415Z" },
+ { url = "https://files.pythonhosted.org/packages/67/5a/2ec9937459827018bb6983e29a5c6f62d519ae3019b359807c068cd2ca46/pyproject_fmt-2.27.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3291f2526bb0071801dd1734ba10062f7363a82123a61810aebf2d823a6bc52b", size = 5096312, upload-time = "2026-08-03T22:54:42.57Z" },
+ { url = "https://files.pythonhosted.org/packages/45/14/fbaa36049d3aaec9f82b83d699cdb9fc2a46dad2ca740168111c312b4cb2/pyproject_fmt-2.27.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:0d7f55de0460109fb892323384ce1bb6cde5b3756a7c62b1465884b42ac648ed", size = 5268557, upload-time = "2026-08-03T22:54:44.37Z" },
+ { url = "https://files.pythonhosted.org/packages/95/c9/dbffa555dd0686c1309e72274d1fb5ad2feaed40469caeec62f5dc32717a/pyproject_fmt-2.27.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:9960ec75a2e1275c7519309838e196cbd2863db918ac8d6cf2edb89d715dbfc3", size = 5662628, upload-time = "2026-08-03T22:54:46.514Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/a5/a41d21f984207e719cf338165440758b561ecd6875c5f682cfe578b9424b/pyproject_fmt-2.27.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:880f3002f702e8c888fd4b129ca699d318ebc3266ed12cc7a2f7b411e56d641f", size = 5267349, upload-time = "2026-08-03T22:54:49.068Z" },
+ { url = "https://files.pythonhosted.org/packages/76/08/a51aa810d4a5518bfd8cb5bf49f55864a7cf88f5aa190fcf8627c6364c80/pyproject_fmt-2.27.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8a6e68a0780828853850c0e284fd67e75c14e7e40d681894f40bca5d17b0d93b", size = 5837708, upload-time = "2026-08-03T22:54:50.846Z" },
+]
+
+[[package]]
+name = "pyproject-hooks"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" },
+]
+
+[[package]]
+name = "pyrefly"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/89/01/a86e9f24722b095c3f88e3616132b75a21b0df53804bdc6a45314dd4d93c/pyrefly-1.2.0.tar.gz", hash = "sha256:5485f960fc2481617068c918335c39ab1507ef90b6b5bd35bf57726e60e73185", size = 6243654, upload-time = "2026-08-01T02:56:27.592Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7d/9d/3c0ef1d4843987b22f996ed381ec9cf5a3b1273e29804db276252e4c95eb/pyrefly-1.2.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7f46d983ac49ddd2b043694960a01dc6a19a5cfd8eec609d6bd9c42866f91b4e", size = 14026305, upload-time = "2026-08-01T02:56:02.611Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/06/03bbb78fbea54cdc65b626619f3597d5611aca4fdef11e72a4e8360e7e63/pyrefly-1.2.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:756f669b5555090f5c1a4fef30db1785fabe657764f7e4e6dc88994dfb8ca82d", size = 13463880, upload-time = "2026-08-01T02:56:04.93Z" },
+ { url = "https://files.pythonhosted.org/packages/13/5a/7d8bc00a38e93bbc9c3e7bd14d305f7948717e667c9bcddeab9dd42fd255/pyrefly-1.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3465812ce5ef4781fb592edbf2724547296f0a3124be115d73c7e8b2401862d", size = 13907329, upload-time = "2026-08-01T02:56:07.104Z" },
+ { url = "https://files.pythonhosted.org/packages/be/94/9e08b4bf799d0b8f36b55a2783c7ba5f51730cf0632a85a67b5b5ed876cd/pyrefly-1.2.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5de7b2ad2bba5c8055181681a84b74143eac2234a48ba5d1b7ed7e7a722b02bd", size = 15039020, upload-time = "2026-08-01T02:56:09.208Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/bd/bca5fd0c80f4daf8ee6903a29df9f3de1feb05ff0946b8f35ec8c5096b13/pyrefly-1.2.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25822ea9505f589ea8a725e4268b475132fb89e038fbf092e446510443ac142a", size = 14986199, upload-time = "2026-08-01T02:56:11.924Z" },
+ { url = "https://files.pythonhosted.org/packages/97/f7/f07087f3d185ad2eced0c56cef89ca5474dfb4ff25f146cd50a861c97553/pyrefly-1.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90efe75e17491ef5d636e10469e9278d7d0256b3b4c5e1f4750069bf3ae0f5d1", size = 14393715, upload-time = "2026-08-01T02:56:14.143Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/70/0d142c320e284b9e3ce35e9b1e58b8ce2ee1f578f2a7234bc30e5022b94f/pyrefly-1.2.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:368aaf7eee4f511ddc0f8e564cf14e01ab2f10b0db9105c6d5b153bf498d07bf", size = 13933008, upload-time = "2026-08-01T02:56:16.525Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/e8/e84f11b6e1f63fd453ad3654213b9a0f6f4de8cef6b58038eef2d0d5955d/pyrefly-1.2.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52d5da7bc65fb7675fbaa80eda879d4f8787c494f04cac21603330d3abbdbbe", size = 14431827, upload-time = "2026-08-01T02:56:18.645Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/06/810d31380f66c75e1c0779a408d3b16117b1b368b57894f6aa66bef21686/pyrefly-1.2.0-py3-none-win32.whl", hash = "sha256:8c90751de8506d938e8f802659c74cf35bd7a0036510ee6c634a38eebb280bfa", size = 13229447, upload-time = "2026-08-01T02:56:20.921Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/98/4dafa3c7a1caed2dc8cc708dde09ba27963c7736508f55b626fff3024113/pyrefly-1.2.0-py3-none-win_amd64.whl", hash = "sha256:8a8964c224ccc4882730130955815de21ff443c1ac3f0b90685b19bf63848170", size = 14087387, upload-time = "2026-08-01T02:56:23.188Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/1c/df3cb0a2e5591660ded7a1836cd2f29dc48c91adb1c0a3a700a96f6d09e1/pyrefly-1.2.0-py3-none-win_arm64.whl", hash = "sha256:3a90bb8df39dfbac74b1f3b2e9d7c526b8f80568884c3944d955023a73ebf61e", size = 13430873, upload-time = "2026-08-01T02:56:25.425Z" },
+]
+
+[[package]]
+name = "pyright"
+version = "1.1.411"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nodeenv" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" },
+]
+
+[[package]]
+name = "pyroma"
+version = "5.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "build" },
+ { name = "docutils" },
+ { name = "packaging" },
+ { name = "pygments" },
+ { name = "requests" },
+ { name = "setuptools" },
+ { name = "trove-classifiers" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0b/68/a91ab78e5d7ff88eaaa10cefc948a07723d263e9a443e3650b32fbf0ac01/pyroma-5.0.1.tar.gz", hash = "sha256:703ae972e53e16be836966d03cd387906ecf32d64992345a61f2ed15805aee56", size = 67392, upload-time = "2025-12-09T10:10:22.357Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/86/cd/300d42aa7675d2ce66fea380177c19ad9f8ffad01295160a06e257360d73/pyroma-5.0.1-py3-none-any.whl", hash = "sha256:e71fd3e0f213b36870a607eccf491241dbadf5462ec1cdda94d08bfa1c26951e", size = 23012, upload-time = "2025-12-09T10:10:20.578Z" },
+]
+
+[[package]]
+name = "pyteenybrisque"
+version = "0.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "pillow" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/81/43/294f291398c93ed695ef208333857394763e50581da3fcef4a3aded3228f/pyteenybrisque-0.1.1.tar.gz", hash = "sha256:eb804b121146056ec6b6d08581f6f19983b565df11e746af0ebe802575c5e56e", size = 182546, upload-time = "2026-05-03T19:28:40.997Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/75/e6/40048a4eb960ee9fc48f7cade4d98274dac591ecc733beacfb3babacf703/pyteenybrisque-0.1.1-py3-none-any.whl", hash = "sha256:d3437290463c62c8479300fad0ab579e1357ce5bfc967e299a4916e24877a426", size = 182791, upload-time = "2026-05-03T19:28:39.587Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
+[[package]]
+name = "pytest-beartype-tests"
+version = "2026.4.26"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beartype" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e6/4f/14167cd06fa425dc70eb486942e5ef402c738984726703584bba55353809/pytest_beartype_tests-2026.4.26.tar.gz", hash = "sha256:a986f59466243b616606279b3c6c00e7e171ee0b5aad4702922ea20fd1a1b52c", size = 88278, upload-time = "2026-04-26T17:12:24.182Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6b/2b/b9c1799582a00a9746f875deecd4449d0dc30254600c77231837bb67c6ec/pytest_beartype_tests-2026.4.26-py3-none-any.whl", hash = "sha256:e0c19a6708a14e4f97acb18ec94151550d70f736733b36d6cd621a6481cea99d", size = 5718, upload-time = "2026-04-26T17:12:22.889Z" },
+]
+
+[[package]]
+name = "pytest-cov"
+version = "7.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage" },
+ { name = "pluggy" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
+]
+
+[[package]]
+name = "pytest-datadir"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b4/46/db060b291999ca048edd06d6fa9ee95945d088edc38b1172c59eeb46ec45/pytest_datadir-1.8.0.tar.gz", hash = "sha256:7a15faed76cebe87cc91941dd1920a9a38eba56a09c11e9ddf1434d28a0f78eb", size = 11848, upload-time = "2025-07-30T13:52:12.518Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8f/7a/33895863aec26ac3bb5068a73583f935680d6ab6af2a9567d409430c3ee1/pytest_datadir-1.8.0-py3-none-any.whl", hash = "sha256:5c677bc097d907ac71ca418109adc3abe34cf0bddfe6cf78aecfbabd96a15cf0", size = 6512, upload-time = "2025-07-30T13:52:11.525Z" },
+]
+
+[[package]]
+name = "pytest-regressions"
+version = "2.11.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+ { name = "pytest-datadir" },
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ba/0f/1d6b8cc851596be52c401af53fd0d844e72921b0b11866b88995673cd320/pytest_regressions-2.11.0.tar.gz", hash = "sha256:d4a86092f979eb25d2403c51b2b61039781c4c7c7a364a56b9fa838872a650f7", size = 117474, upload-time = "2026-05-25T12:09:01.796Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/97/61/fe772eda66bb8e2ee72da2937382aa4f803ca1e41092ca8d59953e96262c/pytest_regressions-2.11.0-py3-none-any.whl", hash = "sha256:fcb2bbcfb2fda256624d434c88cb1e4003667b7e078ee4084887cf839bbdf831", size = 25556, upload-time = "2026-05-25T12:09:00.292Z" },
+]
+
+[[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-dotenv"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
+]
+
+[[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/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 = "regex"
+version = "2026.1.15"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" },
+ { url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" },
+ { url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" },
+ { url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" },
+ { url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" },
+ { url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" },
+ { url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" },
+ { url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" },
+ { url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" },
+ { url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" },
+ { url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" },
+ { url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" },
+ { url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" },
+ { url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" },
+ { url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" },
+ { url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.33.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
+]
+
+[[package]]
+name = "requirements-parser"
+version = "0.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/95/96/fb6dbfebb524d5601d359a47c78fe7ba1eef90fc4096404aa60c9a906fbb/requirements_parser-0.13.0.tar.gz", hash = "sha256:0843119ca2cb2331de4eb31b10d70462e39ace698fd660a915c247d2301a4418", size = 22630, upload-time = "2025-05-21T13:42:05.464Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bd/60/50fbb6ffb35f733654466f1a90d162bcbea358adc3b0871339254fbc37b2/requirements_parser-0.13.0-py3-none-any.whl", hash = "sha256:2b3173faecf19ec5501971b7222d38f04cb45bb9d87d0ad629ca71e2e62ded14", size = 14782, upload-time = "2025-05-21T13:42:04.007Z" },
+]
+
+[[package]]
+name = "responses"
+version = "0.25.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0e/95/89c054ad70bfef6da605338b009b2e283485835351a9935c7bfbfaca7ffc/responses-0.25.8.tar.gz", hash = "sha256:9374d047a575c8f781b94454db5cab590b6029505f488d12899ddb10a4af1cf4", size = 79320, upload-time = "2025-08-08T19:01:46.709Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1c/4c/cc276ce57e572c102d9542d383b2cfd551276581dc60004cb94fe8774c11/responses-0.25.8-py3-none-any.whl", hash = "sha256:0c710af92def29c8352ceadff0c3fe340ace27cf5af1bbe46fb71275bcd2831c", size = 34769, upload-time = "2025-08-08T19:01:45.018Z" },
+]
+
+[[package]]
+name = "respx"
+version = "0.22.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "httpx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" },
+]
+
+[[package]]
+name = "restructuredtext-lint"
+version = "2.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "docutils" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ca/e6/eefcad2228f4124f17e01064428fbcd0ade06a274f3063ce3a126a569d6b/restructuredtext_lint-2.0.2.tar.gz", hash = "sha256:dd25209b9e0b726929d8306339faf723734a3137db382bcf27294fa18a6bc52b", size = 17494, upload-time = "2025-11-23T08:05:18.585Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/af/63/ac52b32b33ae62f2076ed5c4f6b00e065e3ccbb2063e9a2e813b2bfc95bf/restructuredtext_lint-2.0.2-py3-none-any.whl", hash = "sha256:374c0d3e7e0867b2335146a145343ac619400623716b211b9a010c94426bbed7", size = 14198, upload-time = "2025-11-23T08:05:23.267Z" },
+]
+
+[[package]]
+name = "rich"
+version = "14.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
+]
+
+[[package]]
+name = "roman-numerals"
+version = "4.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.16.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" },
+ { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" },
+ { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" },
+ { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" },
+ { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" },
+ { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" },
+ { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" },
+]
+
+[[package]]
+name = "ruyaml"
+version = "0.91.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "distro" },
+ { name = "setuptools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4b/75/abbc7eab08bad7f47887a0555d3ac9e3947f89d2416678c08e025e449fdc/ruyaml-0.91.0.tar.gz", hash = "sha256:6ce9de9f4d082d696d3bde264664d1bcdca8f5a9dff9d1a1f1a127969ab871ab", size = 239075, upload-time = "2021-12-07T16:19:58.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/9a/16ca152a04b231c179c626de40af1d5d0bc2bc57bc875c397706016ddb2b/ruyaml-0.91.0-py3-none-any.whl", hash = "sha256:50e0ee3389c77ad340e209472e0effd41ae0275246df00cdad0a067532171755", size = 108906, upload-time = "2021-12-07T16:19:56.798Z" },
+]
+
+[[package]]
+name = "setuptools"
+version = "80.10.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/86/ff/f75651350db3cf2ef767371307eb163f3cc1ac03e16fdf3ac347607f7edb/setuptools-80.10.1.tar.gz", hash = "sha256:bf2e513eb8144c3298a3bd28ab1a5edb739131ec5c22e045ff93cd7f5319703a", size = 1229650, upload-time = "2026-01-21T09:42:03.061Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl", hash = "sha256:fc30c51cbcb8199a219c12cc9c281b5925a4978d212f84229c909636d9f6984e", size = 1099859, upload-time = "2026-01-21T09:42:00.688Z" },
+]
+
+[[package]]
+name = "setuptools-scm"
+version = "10.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+ { name = "setuptools" },
+ { name = "vcs-versioning" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5d/b1/d0b97ffd2856a7d19c63024a89fb84813cb9d2ed7fa8fdbedf9e2f13a9ab/setuptools_scm-10.2.1.tar.gz", hash = "sha256:4fa7dd82cf8c800df59c9a288c90299b1657ff1ecfc3f5cc00287c5dbf5e27a9", size = 154237, upload-time = "2026-07-21T08:08:04.553Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c3/b4/02ac1d9833b882c87b3a4e82703e70eac4ab33d0d15fb123e60bb01f3bc1/setuptools_scm-10.2.1-py3-none-any.whl", hash = "sha256:b7c82f4102d389ee57dc66ccdb4f9b4bca3c40ba83b43f1f63d68ccd72db2580", size = 29078, upload-time = "2026-07-21T08:08:03.293Z" },
+]
+
+[[package]]
+name = "shellcheck-py"
+version = "0.11.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/df/55/455b097417b3df3d330eff029c72c32f08b25739e3010acb30ad06d268ef/shellcheck_py-0.11.0.1.tar.gz", hash = "sha256:5c620c88901e8f1d3be5934b31ea99e3310065e1245253741eafd0a275c8c9cc", size = 3139, upload-time = "2025-08-09T17:53:42.492Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/27/d75b03e5458cefdb6d3b674566cd20476c3e4d3fe6cc9d68b7e3b854b296/shellcheck_py-0.11.0.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b6a3fee28efda2e16e38d6e6d59faf7224300256456639727370d404730849e8", size = 6774472, upload-time = "2025-08-09T17:53:34.573Z" },
+ { url = "https://files.pythonhosted.org/packages/61/ac/2a84c37171c0cf5a10ea4b0a27d43eb0a1d29bd98b49c2c5ffe17ad24bbe/shellcheck_py-0.11.0.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:6b88d0a244c82ed07e06a53e444da841f69330ca59ae15d4a66c391655dae7a0", size = 11381835, upload-time = "2025-08-09T17:53:36.852Z" },
+ { url = "https://files.pythonhosted.org/packages/96/55/250e0e3367613a5c22bd82e33b16b889287d81ab0f7dda67e6514a4cccf4/shellcheck_py-0.11.0.1-py2.py3-none-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1b274df81de5b000ff78db433e7328b87e52e3c38481c60f8e488c3095beef05", size = 3800600, upload-time = "2025-08-09T17:53:38.643Z" },
+ { url = "https://files.pythonhosted.org/packages/15/5b/bb14c0a7474463b1aa3c09e866cb172dffc66ed2993b7ea8f1db581e86ee/shellcheck_py-0.11.0.1-py2.py3-none-win_amd64.whl", hash = "sha256:784156289ecb17e91c692cd783ab5152333309588cabb10032a047331c63e759", size = 8027541, upload-time = "2025-08-09T17:53:40.889Z" },
+]
+
+[[package]]
+name = "shellingham"
+version = "1.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
+]
+
+[[package]]
+name = "shfmt-py"
+version = "4.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/d5/c2ad5c6593a34da7344cf39bde65763e8cda752589074ba1619e55b317ad/shfmt_py-4.0.0.tar.gz", hash = "sha256:1e5fdacf40aabaa77a97639d52a6220df0893b46658d82b7f136f4e66e2b2fb0", size = 11947, upload-time = "2026-05-13T09:25:50.153Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/1d/8f72824e2a0e06dc0bc2687baacaba0573be7d2e93c01d1e895fddd8c13e/shfmt_py-4.0.0-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:75a4919a03fb3bcff9795e3cc7b971e37e74905654d2f11605001cab42e5f92f", size = 1343695, upload-time = "2026-05-13T09:25:42.969Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/82/9564a2c2a76fbec94db1b3a3c37a9a1d00e7eafca2cdd2e0d19082618d7e/shfmt_py-4.0.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:bb3d236163ff39c7790953e069938caf247e7646399f7a059f00f65d4e6916d6", size = 1237947, upload-time = "2026-05-13T09:25:44.767Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a9/6fce944efa530db941edd11388d70dc7384aaf12169a3b0847b6a6c987b0/shfmt_py-4.0.0-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:4701336c3cb5f3959a5e85481b14f02054ea094b3c666f3d04649bbe10de3c25", size = 1218771, upload-time = "2026-05-13T09:25:46.225Z" },
+ { url = "https://files.pythonhosted.org/packages/64/43/e3965a25bb39555f2791c6860214f62b6f976f9ac7e9786073364bcdd9a6/shfmt_py-4.0.0-py2.py3-none-manylinux2014_x86_64.whl", hash = "sha256:e57877abe0177a9da7bbb5390fe7e96aa19b00958189a025634039aef8834d44", size = 1350939, upload-time = "2026-05-13T09:25:47.584Z" },
+ { url = "https://files.pythonhosted.org/packages/95/20/db2430d9262d2cffadcad2b330441e13031f1ab849ec069659edb7f23257/shfmt_py-4.0.0-py2.py3-none-win_amd64.whl", hash = "sha256:bd4f3d36264d4ba8b014ff73e5e702aaa2345845c021f563480128de3705135b", size = 1427721, upload-time = "2026-05-13T09:25:48.865Z" },
+]
+
+[[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 = "snowballstemmer"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" },
+]
+
+[[package]]
+name = "soupsieve"
+version = "2.8.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
+]
+
+[[package]]
+name = "sphinx"
+version = "9.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "alabaster" },
+ { name = "babel" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "docutils" },
+ { name = "imagesize" },
+ { name = "jinja2" },
+ { name = "packaging" },
+ { name = "pygments" },
+ { name = "requests" },
+ { name = "roman-numerals" },
+ { name = "snowballstemmer" },
+ { name = "sphinxcontrib-applehelp" },
+ { name = "sphinxcontrib-devhelp" },
+ { name = "sphinxcontrib-htmlhelp" },
+ { name = "sphinxcontrib-jsmath" },
+ { name = "sphinxcontrib-qthelp" },
+ { name = "sphinxcontrib-serializinghtml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" },
+]
+
+[[package]]
+name = "sphinx-basic-ng"
+version = "1.0.0b2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" },
+]
+
+[[package]]
+name = "sphinx-click"
+version = "6.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "docutils" },
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9a/ed/a9767cd1b8b7fbdf260a89d5c8c86e20e3536b9878579e5ab7965a291e55/sphinx_click-6.2.0.tar.gz", hash = "sha256:fc78b4154a4e5159462e36de55b8643747da6cda86b3b52a8bb62289e603776c", size = 27035, upload-time = "2025-12-04T19:33:05.437Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/bd/cb244695f67f77b0a36200ce1670fc42a6fe2770847e870daab99cc2b177/sphinx_click-6.2.0-py3-none-any.whl", hash = "sha256:1fb1851cb4f2c286d43cbcd57f55db6ef5a8d208bfc3370f19adde232e5803d7", size = 8939, upload-time = "2025-12-04T19:33:04.037Z" },
+]
+
+[[package]]
+name = "sphinx-copybutton"
+version = "0.5.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" },
+]
+
+[[package]]
+name = "sphinx-inline-tabs"
+version = "2025.12.21.14"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/6a/f39bde46a79b80a9983233d99b773bd24b468bdd9c1e87acb46ff69af441/sphinx_inline_tabs-2025.12.21.14.tar.gz", hash = "sha256:c71a75800326e613fb4e410eed92a0934214741326aca9897c18018b9f968cb6", size = 45572, upload-time = "2025-12-21T13:30:51.071Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/2b/e64e7de34663cff1df029ba4f05a86124315bd9eba3d3b78e64904bea7e0/sphinx_inline_tabs-2025.12.21.14-py3-none-any.whl", hash = "sha256:e685c782b58d4e01490bcc4e2367cf7135ec28e7283a05e89095394e4ca6e81a", size = 7082, upload-time = "2025-12-21T13:30:50.142Z" },
+]
+
+[[package]]
+name = "sphinx-lint"
+version = "1.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "polib" },
+ { name = "regex" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8a/19/9258497fee6e2a0bdb93e8ecea6ef6864afb5d83e996a1606a853f96c658/sphinx_lint-1.0.2.tar.gz", hash = "sha256:4e7fc12f44f750b0006eaad237d7db9b1d8aba92adda9c838af891654b371d35", size = 36870, upload-time = "2025-11-19T08:28:12.269Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c0/62/f29a2988ff706ac01d3c63d0b4cc4ed62d2c83b447916e0317790ca156cf/sphinx_lint-1.0.2-py3-none-any.whl", hash = "sha256:edcd0fa4d916386c5a3ef7ef0f5136f0bb4a15feefc83c1068ba15bc16eec652", size = 20670, upload-time = "2025-11-19T08:28:10.656Z" },
+]
+
+[[package]]
+name = "sphinx-pyproject"
+version = "0.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "dom-toml" },
+ { name = "domdf-python-tools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/39/97/aa8cec3da3e78f2c396b63332e2fe92fe43f7ff2ad19b3998735f28b0a7f/sphinx_pyproject-0.3.0.tar.gz", hash = "sha256:efc4ee9d96f579c4e4ed1ac273868c64565e88c8e37fe6ec2dc59fbcd57684ab", size = 7695, upload-time = "2023-08-18T21:43:45.473Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/d5/89cb47c6399fd57ca451af15361499813c5d53e588cb6e00d89411ce724f/sphinx_pyproject-0.3.0-py3-none-any.whl", hash = "sha256:3aca968919f5ecd390f96874c3f64a43c9c7fcfdc2fd4191a781ad9228501b52", size = 23076, upload-time = "2023-08-18T21:43:43.808Z" },
+]
+
+[[package]]
+name = "sphinx-substitution-extensions"
+version = "2026.8.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beartype" },
+ { name = "docutils" },
+ { name = "myst-parser" },
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/07/d9/d7bd4d3a396b05fa441b46e70d6b50971b219a521ffef5cc1f17f2aff7ed/sphinx_substitution_extensions-2026.8.5.tar.gz", hash = "sha256:c64c0c3cd4d2d4c59d761252876b0fd8367e1023bc54e333bf06b1a434965503", size = 42300, upload-time = "2026-08-05T12:21:17.361Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/66/2a/8d3315e55c53c56db1a178e78a6081d65fde7607f6ae8f9b81e91cf5e8d9/sphinx_substitution_extensions-2026.8.5-py3-none-any.whl", hash = "sha256:2eda9c056ccf760cf227c0f76cfc6e7875c89fa8edacf64e7518fcfbd029c623", size = 11773, upload-time = "2026-08-05T12:21:16.21Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-applehelp"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-devhelp"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-htmlhelp"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-jsmath"
+version = "1.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-qthelp"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-serializinghtml"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-spelling"
+version = "8.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyenchant" },
+ { name = "requests" },
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4b/cd/fa8039cedce6295644ff5f03367742d21e7922da426e8c666b7f5a213682/sphinxcontrib_spelling-8.0.2.tar.gz", hash = "sha256:afbc7b8e93721ab88f12bdd39d848b92017b3763b9ed6226b4b0e54b06664fea", size = 30955, upload-time = "2025-11-28T15:31:50.235Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6e/c5/bcd32aa919c9e1652cca5bed6478202656a320c407793b547f8c16c179e3/sphinxcontrib_spelling-8.0.2-py3-none-any.whl", hash = "sha256:db8b3b2945683d49e87a8a5133d2b8ed4206cb593038b986ca8686a485f9980d", size = 14587, upload-time = "2025-11-28T15:31:48.957Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-towncrier"
+version = "0.5.0a0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx" },
+ { name = "towncrier" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/21/fe/72ed57093e28af10595c50839b183c5fdf0952482e9ef0ca6eb90eb85c5d/sphinxcontrib_towncrier-0.5.0a0.tar.gz", hash = "sha256:294e69df6e275e7a86df7ea6a927cc7c28c2c370a884cd5c45de6ec989858f27", size = 62453, upload-time = "2025-02-28T01:59:16.894Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ac/5c/f7e39f243636a5e1894f2f5a72579977bf3968922afdb75175ee45062066/sphinxcontrib_towncrier-0.5.0a0-py3-none-any.whl", hash = "sha256:11d130c3ad5e4649821d543c4ea7ab64bbe78df4d859ef94f4298e7845dc0f59", size = 12609, upload-time = "2025-02-28T01:59:15.178Z" },
+]
+
+[[package]]
+name = "stevedore"
+version = "5.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/5b/496f8abebd10c3301129abba7ddafd46c71d799a70c44ab080323987c4c9/stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945", size = 516074, upload-time = "2025-11-20T10:06:07.264Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/40/8561ce06dc46fd17242c7724ab25b257a2ac1b35f4ebf551b40ce6105cfa/stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820", size = 54428, upload-time = "2025-11-20T10:06:05.946Z" },
+]
+
+[[package]]
+name = "strict-kwargs"
+version = "2026.7.24"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ty" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/ea/5be0b5ba196632ae08842b7e7a45c2baa4ad14be3aa0c410e9ffede761d2/strict_kwargs-2026.7.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:94a8679e751918df94239ae4513ce99d89653cbdf55eb7d9181037ded0a79562", size = 2897038, upload-time = "2026-07-24T09:33:29.353Z" },
+ { url = "https://files.pythonhosted.org/packages/88/dd/5e23899b7ff873217aead4d0f6a89fa50b1fd7c0cac04c93d713ae9ba4a8/strict_kwargs-2026.7.24-py3-none-manylinux_2_39_x86_64.whl", hash = "sha256:b788a347f66f5ee1ebc424632a2bce223ed0162da97a12327af75e0438df2836", size = 3084814, upload-time = "2026-07-24T09:33:30.907Z" },
+ { url = "https://files.pythonhosted.org/packages/43/ad/ef1e18303b9684e81dc9ffdb4ec3e8bb5065d5b8ec1b1da6cae0451341cc/strict_kwargs-2026.7.24-py3-none-win_amd64.whl", hash = "sha256:b727e83eff48ef0c6e14618f1ce04e70e4b9b8c7f0ee5f2a546bf8d30e77c729", size = 2799438, upload-time = "2026-07-24T09:33:32.471Z" },
+]
+
+[[package]]
+name = "sybil"
+version = "9.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c6/46/bae21847b8d761ddd6ede1811d32818342dbd482c32a2a5805c28d9b2f18/sybil-9.3.0.tar.gz", hash = "sha256:847d1d17b8a857c4bb3f8471b4a57b8affa939a60fbf507e70aa72ad79097c05", size = 89078, upload-time = "2025-12-02T09:29:09.302Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/08/cd3cf2a82570748cfb3142e795044197deff81ad3b70a0b9a9c22331e70a/sybil-9.3.0-py3-none-any.whl", hash = "sha256:0b108b980ab9fac774953042b07fcb5858aa19a38404d0cb42c30c93423ac0c1", size = 39286, upload-time = "2025-12-02T09:29:08.417Z" },
+]
+
+[[package]]
+name = "sybil-extras"
+version = "2026.7.19"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beartype" },
+ { name = "markdown-it-py" },
+ { name = "myst-parser" },
+ { name = "sybil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/96/3c/27b1055b5afb809f055c68ba5858a4a498757f54f90827e16918ce53f39d/sybil_extras-2026.7.19.tar.gz", hash = "sha256:4ee756a2da38287a957bde7edbf295951cdd65407ca4344a1726dfff3c1d64ba", size = 118325, upload-time = "2026-07-19T13:47:44.707Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/60/1d/d09751fe1bc2d9029c5123ce4c5fb11ead2d09c8f227a8f4658ebe3e7afa/sybil_extras-2026.7.19-py3-none-any.whl", hash = "sha256:794cb004f1f8d2b7e32b7ca1af455972d382053c1c0d03ac2da8c4089616c9fb", size = 89540, upload-time = "2026-07-19T13:47:43.107Z" },
+]
+
+[[package]]
+name = "sympy"
+version = "1.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mpmath" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
+]
+
+[[package]]
+name = "tabulate"
+version = "0.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" },
+]
+
+[[package]]
+name = "tomli"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
+ { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
+ { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
+ { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
+ { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
+ { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
+ { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
+ { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
+]
+
+[[package]]
+name = "tomlkit"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" },
+]
+
+[[package]]
+name = "torch"
+version = "2.13.0"
+source = { registry = "https://download.pytorch.org/whl/cpu" }
+resolution-markers = [
+ "python_full_version < '3.15' and sys_platform == 'darwin'",
+]
+dependencies = [
+ { name = "filelock", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
+ { name = "fsspec", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
+ { name = "jinja2", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
+ { name = "networkx", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
+ { name = "setuptools", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
+ { name = "sympy", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
+]
+wheels = [
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d849b390e07d8d333ce8ecaf91b273c656c598379a19c9acf1318a883f6b391c", upload-time = "2026-07-08T12:26:28Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c28def70706c2f9ecc752574766e8ae4da9b810ab6676b611166761a78a9f1e1", upload-time = "2026-07-08T12:26:33Z" },
+]
+
+[[package]]
+name = "torch"
+version = "2.13.0+cpu"
+source = { registry = "https://download.pytorch.org/whl/cpu" }
+resolution-markers = [
+ "python_full_version >= '3.15'",
+ "python_full_version < '3.15' and sys_platform != 'darwin'",
+]
+dependencies = [
+ { name = "filelock", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" },
+ { name = "fsspec", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" },
+ { name = "jinja2", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" },
+ { name = "networkx", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" },
+ { name = "setuptools", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" },
+ { name = "sympy", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" },
+ { name = "typing-extensions", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" },
+]
+wheels = [
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:dec241fef3984c0d1edadd1f58708e218d4eae881ceef7bc10cf9964d41b68b9", upload-time = "2026-07-08T19:30:20Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:ca021f9eb2f8345c83fa03e3a04587308afb8df71bd472670b3ece00df58621c", upload-time = "2026-07-08T19:30:32Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d20fa53ee744502fa4c69818a720b05ca0d37abd055d4f6e66cae155114bc691", upload-time = "2026-07-08T19:30:45Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:e2e5134decf00e218da62318f3dc5df156231d367871918e91eba95ab0ad43ab", upload-time = "2026-07-08T19:30:58Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:991cc14b39e751122c01f017be6448533989868731cb5eecd1006893d26787c2", upload-time = "2026-07-08T19:31:09Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:7b8d26e29bceafbdaa8d63bfe7612f23875b5af2cc07e13f809c3ed890bbe1d8", upload-time = "2026-07-08T19:31:21Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:b222c15a0fc2ce207d1c1a59700b46c8fa6748df1f447ad11e5c870dde0933d9", upload-time = "2026-07-08T19:31:35Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:a43376bd094124ef626bfdd3d4c2c62eacb0b5ddc99776f4a32d4fd16f1f3420", upload-time = "2026-07-08T19:31:48Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5002ca81af00ae69b57540f615b58b8ae922b6d4848176b366a52bd2196e6", upload-time = "2026-07-08T19:32:00Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:1a3a35229fdc13446b4eab50e7fcf9399ff941e89a3b761497786297a5d8dde5", upload-time = "2026-07-08T19:32:16Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:8e109528e6bab044815daebaf71770fbaace3a66ef1c816cb55c875350f78a60", upload-time = "2026-07-08T19:32:30Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:222a6681467cc7f6f05cd3068dfbc603def3a1e46d1d4620c1c8cdf6178bd563", upload-time = "2026-07-08T19:32:44Z" },
+]
+
+[[package]]
+name = "torchvision"
+version = "0.28.0"
+source = { registry = "https://download.pytorch.org/whl/cpu" }
+resolution-markers = [
+ "python_full_version < '3.15' and sys_platform == 'darwin'",
+]
+dependencies = [
+ { name = "numpy", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
+ { name = "pillow", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
+ { name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
+]
+wheels = [
+ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.28.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3bd9dba55224a9db4a2d77f6feaa5651770d8c8e86d3d0ddb0fa6bec54c8712b", upload-time = "2026-07-08T12:26:40Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.28.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:09ce8f56e81f19b9c378ae7bb109f83f6659fd8bc3cd14241a48e4af46e9ed49", upload-time = "2026-07-08T12:26:40Z" },
+]
+
+[[package]]
+name = "torchvision"
+version = "0.28.0+cpu"
+source = { registry = "https://download.pytorch.org/whl/cpu" }
+resolution-markers = [
+ "python_full_version >= '3.15'",
+ "python_full_version < '3.15' and sys_platform != 'darwin'",
+]
+dependencies = [
+ { name = "numpy", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" },
+ { name = "pillow", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" },
+ { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" },
+]
+wheels = [
+ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.28.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a1a76c8decb1d7bbedd3588bccc90fb269944b7321a773db181735b42115422", upload-time = "2026-07-08T12:26:39Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.28.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:1aa741ae0eb8668b6287dd667548e2dd10179c828db68bfdee1519763b9c5b99", upload-time = "2026-07-08T12:26:39Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.28.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:d2a0171faa211b506c4dcf3a036942a41077c5d2d3d94883dafbda7b8624a3eb", upload-time = "2026-07-08T12:26:39Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.28.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:8d8b98608779c770ede5e20609772453ebc7487ebb8697445d1856466c542f45", upload-time = "2026-07-08T12:26:39Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.28.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:870d0d42f2eb80f4870cd35e51eea52f596a408a671b28136f06a808846f24c5", upload-time = "2026-07-08T12:26:39Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.28.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:82dbffb63d61cd43d9c7a311588e665aa2b21173a05f852b4d384d6782fd88ef", upload-time = "2026-07-08T12:26:39Z" },
+]
+
+[[package]]
+name = "towncrier"
+version = "25.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "jinja2" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c2/eb/5bf25a34123698d3bbab39c5bc5375f8f8bcbcc5a136964ade66935b8b9d/towncrier-25.8.0.tar.gz", hash = "sha256:eef16d29f831ad57abb3ae32a0565739866219f1ebfbdd297d32894eb9940eb1", size = 76322, upload-time = "2025-08-30T11:41:55.393Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/42/06/8ba22ec32c74ac1be3baa26116e3c28bc0e76a5387476921d20b6fdade11/towncrier-25.8.0-py3-none-any.whl", hash = "sha256:b953d133d98f9aeae9084b56a3563fd2519dfc6ec33f61c9cd2c61ff243fb513", size = 65101, upload-time = "2025-08-30T11:41:53.644Z" },
+]
+
+[[package]]
+name = "trove-classifiers"
+version = "2026.1.14.14"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/43/7935f8ea93fcb6680bc10a6fdbf534075c198eeead59150dd5ed68449642/trove_classifiers-2026.1.14.14.tar.gz", hash = "sha256:00492545a1402b09d4858605ba190ea33243d361e2b01c9c296ce06b5c3325f3", size = 16997, upload-time = "2026-01-14T14:54:50.526Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bb/4a/2e5583e544bc437d5e8e54b47db87430df9031b29b48d17f26d129fa60c0/trove_classifiers-2026.1.14.14-py3-none-any.whl", hash = "sha256:1f9553927f18d0513d8e5ff80ab8980b8202ce37ecae0e3274ed2ef11880e74d", size = 14197, upload-time = "2026-01-14T14:54:49.067Z" },
+]
+
+[[package]]
+name = "ty"
+version = "0.0.70"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/ed/38a8ab52f1d7c3ed701442a31b23ba774cbc5d6909f2c00da9e1f3c590f9/ty-0.0.70.tar.gz", hash = "sha256:a01bebc128b4081c16002965d906fccb21323d69bb709b9108c1f2406bcffced", size = 6601156, upload-time = "2026-08-10T23:20:26.268Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3d/c5/ddd6cc5657fd3da85591b264f4dabb1ce5e5b535e73fd5174991c294978b/ty-0.0.70-py3-none-linux_armv6l.whl", hash = "sha256:4fb2d2e55e2160c07152361be2e1a26fdd4f6261055731317d5972daf1749935", size = 12537215, upload-time = "2026-08-10T23:19:43.512Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/8a/acc9b34331cde81e0d63cda92d4db9e4042def5b1efa7d47431ff1d30d17/ty-0.0.70-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9b04d4c21cb029501c05598ca21e9c0829c0b2e35a7ceba1e5b78014c1e8104d", size = 12149070, upload-time = "2026-08-10T23:19:46.369Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/cd/51ac2708f4d077058a0bfeefa40d630883c5ec7a82fd2c15c86519140235/ty-0.0.70-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c605ebf2643f5e64ec4bcb269640a1aa85966d29fa888fb746039c28570369b2", size = 11625729, upload-time = "2026-08-10T23:19:48.535Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/99/afc4fe7e630100dc782ff0cdc8c59c01acfb05299551ff0ef49c93814320/ty-0.0.70-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c38ea76e12909c29e18fcd295ff223b63f3701c1d1c34db2cdc9736340b9de2", size = 12204810, upload-time = "2026-08-10T23:19:51.845Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/38/8cabc3c8ad4c3a02e585ecff12a71ff8e5881f8a00ee71745fd765201da5/ty-0.0.70-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c1ee6bf4edaabf7bd0307f0c0f9ddc2204df0390820fe89f6a9de65f07722aba", size = 12308114, upload-time = "2026-08-10T23:19:54.229Z" },
+ { url = "https://files.pythonhosted.org/packages/44/7e/bb4e552ecd68bb490bae9368ef323e3d0c79ef85a811857139a2d0f59ddb/ty-0.0.70-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5eef3e11d7d6b800ef66da0cce8ea24f8be804d1ebf359683426cfe848729bf8", size = 13072240, upload-time = "2026-08-10T23:19:56.872Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/c4/1861e1d554e5b6e0d11b3a9f36d40c372253f44a3c4e56ad4bf840f2801e/ty-0.0.70-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a95e4ae7c2599197c9d89652e49ca344ab224f7d12376e6ad8beb0587e8ff83f", size = 13497678, upload-time = "2026-08-10T23:19:59.191Z" },
+ { url = "https://files.pythonhosted.org/packages/77/22/3b22442133e8f641485e59e463a070a31184db0d6456851f871db8f59763/ty-0.0.70-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:06aca758d1e0016c0a1f57fe9d8de7a21ff83f692306f747a0d97f32de24e27f", size = 13232510, upload-time = "2026-08-10T23:20:01.456Z" },
+ { url = "https://files.pythonhosted.org/packages/84/b8/911f1e6885b5485b6e1d29aaab19ce5e6deeb3e931b6ad82296da4f22051/ty-0.0.70-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d81825524f1b57ecbcb5fce7d61fb159cb4837a6167a4569309c9fa7fc15a77d", size = 12817128, upload-time = "2026-08-10T23:20:04.275Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/a6/9affc3ca11c32d75b348a66144ab83335fa7fa7100b1581356725e15a668/ty-0.0.70-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3287dfb09f7320ef28f114f5f9aae5f697b7b1a6ee37fb2a4a3be94481c1b4f8", size = 13089208, upload-time = "2026-08-10T23:20:06.574Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/cb/4776108ea08ec4013b71375b101be9c8a632967cd10f24abbdd4664d66f4/ty-0.0.70-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9fb1877f6401cdaac4db46c5bf762f327482ba5f891420ea462ebd15d0de4185", size = 12148890, upload-time = "2026-08-10T23:20:08.827Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/d9/814e71f698d9231ef6412bd2c1499d81c6f86f66c2764e952c991e2d6da5/ty-0.0.70-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e78f4997dbb0db2d2270210eb3694466206e5b3a11985e24148770e702158a25", size = 12327084, upload-time = "2026-08-10T23:20:11.023Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/69/9f34bba534c5d1ace391d300ead4f1971f4348c5459e66dc466cfd60661c/ty-0.0.70-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cf758d3b2dad910c9b1d22d2d62fea894b5bc9acd3c00365e8a73ace6090a452", size = 12604372, upload-time = "2026-08-10T23:20:13.366Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/ff/abb34674517b29a8e2489a39c4e01930fcf6aab993a7d6b33aa97f119117/ty-0.0.70-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0d338761617279a4fb6a83e7fad7e21126394637b8c45e13e196b2bb675f39b2", size = 12917800, upload-time = "2026-08-10T23:20:15.841Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/1a/f8289572f4fad5cb16c883b94638548166bc78cf3eb569f0cd9199eb10a0/ty-0.0.70-py3-none-win32.whl", hash = "sha256:a45642cf09dde91f0a3ce9b9e6fffda9779ff69d73f7347c18acf4fb45007c07", size = 11921482, upload-time = "2026-08-10T23:20:18.244Z" },
+ { url = "https://files.pythonhosted.org/packages/17/ae/8739d7618b4670c3ee4f52d641d53be87928bd121d8bda9c0e3450500b75/ty-0.0.70-py3-none-win_amd64.whl", hash = "sha256:33e7941a926cf39b82553911a59a6ed68ec98c3d3d5a415df633f4d1cd051e6c", size = 12986994, upload-time = "2026-08-10T23:20:20.53Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/44/2bc3301ba4356ad8866daac9f8cfb3687953e52d076ea204f113b9304c42/ty-0.0.70-py3-none-win_arm64.whl", hash = "sha256:0d380f735d52b1d4b773193f8f5c58c065725eab1ebb0008d38a7b830de14f47", size = 12301082, upload-time = "2026-08-10T23:20:23.816Z" },
+]
+
+[[package]]
+name = "typer"
+version = "0.21.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "rich" },
+ { name = "shellingham" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" },
+]
+
+[[package]]
+name = "types-pyyaml"
+version = "6.0.12.20260724"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3f/6f/a28f44bcd56bebed42b028a2894c79853e2f5e6b5279e633cb3f287a05e7/types_pyyaml-6.0.12.20260724.tar.gz", hash = "sha256:3c1ce1bb73cd5ec02e90390c2b1f00e810d241d8825fd73ff359696839271b6b", size = 17893, upload-time = "2026-07-24T04:58:43.453Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8b/42/0337fefc615e20ee55d1c8f71b774a9b2b734a04669139c20753b27a2a3a/types_pyyaml-6.0.12.20260724-py3-none-any.whl", hash = "sha256:d57db930a4b2efbc57cf430ec8882765d246929432fa253092f383902329a453", size = 20312, upload-time = "2026-07-24T04:58:42.486Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "tzdata"
+version = "2025.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
+]
+
+[[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 = "vale"
+version = "3.13.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0d/0d/6ebd7d020135888cc4d35290737871986ceabf176ba5e44827294429283a/vale-3.13.0.0.tar.gz", hash = "sha256:9c2482ecab515e58aa8d7e1a09f3d44ed674a07502786e22ff60c9d4fdc8493b", size = 5340, upload-time = "2025-10-28T13:20:41.459Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/05/92b9e4d3e3cb424d2a1aa6e070ee838f15f6739a90b06964156a24fe49ce/vale-3.13.0.0-py3-none-any.whl", hash = "sha256:b565197a5f6e430af7ccc59204e75c6067bbd091a0073d700efdd0fba21873ed", size = 5944, upload-time = "2025-10-28T13:20:40.35Z" },
+]
+
+[[package]]
+name = "vcs-versioning"
+version = "2.1.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/65/8b/4c53c4c161d484e74685fd03768f8874c0beb96c549a58011d3e456f61c9/vcs_versioning-2.1.2.tar.gz", hash = "sha256:7282122aee5de69d520a9dbe56ebac705a0be7e2cc9b1b97dde4333c45fe782c", size = 131716, upload-time = "2026-06-23T21:36:29.079Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/62/6fe8d9f670980e9e1c879eed5653cd24b6015a892873c206b4ae3fd35526/vcs_versioning-2.1.2-py3-none-any.whl", hash = "sha256:d559440f0cb46b6672e65492c34eadd45a736d61d5b28d43ae1ad37bc4a9f06f", size = 107212, upload-time = "2026-06-23T21:36:27.881Z" },
+]
+
+[[package]]
+name = "vulture"
+version = "2.16"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/66/3e/4d08c5903b2c0c70cad583c170cc4a663fc6a61e2ad00b711fcda61358cd/vulture-2.16.tar.gz", hash = "sha256:f8d9f6e2af03011664a3c6c240c9765b3f392917d3135fddca6d6a68d359f717", size = 52680, upload-time = "2026-03-25T14:41:27.141Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" },
+]
+
+[[package]]
+name = "vws-auth-tools"
+version = "2024.7.12"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a9/17/421ff3a46cee7d952e3da3126160fb75ab7cb54e3fbbfa718a7ee9e120d4/vws_auth_tools-2024.7.12.tar.gz", hash = "sha256:e3949606f2366053ea97883992f8ecaf95030ea33f1b3cf769f99f9d43c0914b", size = 19097, upload-time = "2024-07-12T16:59:31.845Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ff/34/d6c791bffdc3cb2e920468d255d0fa23366cb8415c1ba3db26127cc1c789/vws_auth_tools-2024.7.12-py2.py3-none-any.whl", hash = "sha256:673bb0be98e2112a008f3146ab24a0276dc26de8c43cb40546d9a54821cb9e48", size = 5327, upload-time = "2024-07-12T16:59:30.287Z" },
+]
+
+[[package]]
+name = "vws-cli"
+source = { editable = "." }
+dependencies = [
+ { name = "beartype" },
+ { name = "click" },
+ { name = "pyyaml" },
+ { name = "vws-python" },
+]
+
+[package.optional-dependencies]
+dev = [
+ { name = "actionlint-py" },
+ { name = "check-manifest" },
+ { name = "deptry" },
+ { name = "doc8" },
+ { name = "doccmd" },
+ { name = "freezegun" },
+ { name = "furo" },
+ { name = "hadolint-bin", marker = "sys_platform != 'win32'" },
+ { name = "interrogate" },
+ { name = "mypy", extra = ["faster-cache"] },
+ { name = "mypy-strict-kwargs" },
+ { name = "no-defaults" },
+ { name = "prek" },
+ { name = "pydocstringformatter" },
+ { name = "pylint", extra = ["spelling"] },
+ { name = "pyproject-fmt" },
+ { name = "pyrefly" },
+ { name = "pyright" },
+ { name = "pyroma" },
+ { name = "pytest" },
+ { name = "pytest-beartype-tests" },
+ { name = "pytest-cov" },
+ { name = "pytest-regressions" },
+ { name = "ruff" },
+ { name = "setuptools-scm" },
+ { name = "shellcheck-py" },
+ { name = "shfmt-py" },
+ { name = "sphinx" },
+ { name = "sphinx-click" },
+ { name = "sphinx-copybutton" },
+ { name = "sphinx-inline-tabs" },
+ { name = "sphinx-lint" },
+ { name = "sphinx-pyproject" },
+ { name = "sphinx-substitution-extensions" },
+ { name = "sphinxcontrib-spelling" },
+ { name = "sphinxcontrib-towncrier" },
+ { name = "strict-kwargs" },
+ { name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
+ { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" },
+ { name = "torchvision", version = "0.28.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
+ { name = "torchvision", version = "0.28.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" },
+ { name = "towncrier" },
+ { name = "ty" },
+ { name = "types-pyyaml" },
+ { name = "vale" },
+ { name = "vulture" },
+ { name = "vws-python-mock" },
+ { name = "vws-test-fixtures" },
+ { name = "yamlfix" },
+ { name = "zizmor" },
+]
+release = [
+ { name = "check-wheel-contents" },
+ { name = "homebrew-pypi-poet" },
+ { name = "towncrier" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "actionlint-py", marker = "extra == 'dev'", specifier = "==1.7.12.24" },
+ { name = "beartype", specifier = "==0.22.9" },
+ { name = "check-manifest", marker = "extra == 'dev'", specifier = "==0.51" },
+ { name = "check-wheel-contents", marker = "extra == 'release'", specifier = "==0.6.3" },
+ { name = "click", specifier = "==8.4.2" },
+ { name = "deptry", marker = "extra == 'dev'", specifier = "==0.25.1" },
+ { name = "doc8", marker = "extra == 'dev'", specifier = "==2.0.0" },
+ { name = "doccmd", marker = "extra == 'dev'", specifier = "==2026.7.19" },
+ { name = "freezegun", marker = "extra == 'dev'", specifier = "==1.5.5" },
+ { name = "furo", marker = "extra == 'dev'", specifier = "==2025.12.19" },
+ { name = "hadolint-bin", marker = "sys_platform != 'win32' and extra == 'dev'", specifier = "==2.15.1" },
+ { name = "homebrew-pypi-poet", marker = "extra == 'release'", specifier = "==0.10.0" },
+ { name = "interrogate", marker = "extra == 'dev'", specifier = "==1.7.0" },
+ { name = "mypy", extras = ["faster-cache"], marker = "extra == 'dev'", specifier = "==2.3.0" },
+ { name = "mypy-strict-kwargs", marker = "extra == 'dev'", specifier = "==2026.7.19.1" },
+ { name = "no-defaults", marker = "extra == 'dev'", specifier = "==2.1.0" },
+ { name = "prek", marker = "extra == 'dev'", specifier = "==0.4.13" },
+ { name = "pydocstringformatter", marker = "extra == 'dev'", specifier = "==1.0.0" },
+ { name = "pylint", extras = ["spelling"], marker = "extra == 'dev'", specifier = "==4.0.7" },
+ { name = "pyproject-fmt", marker = "extra == 'dev'", specifier = "==2.27.0" },
+ { name = "pyrefly", marker = "extra == 'dev'", specifier = "==1.2.0" },
+ { name = "pyright", marker = "extra == 'dev'", specifier = "==1.1.411" },
+ { name = "pyroma", marker = "extra == 'dev'", specifier = "==5.0.1" },
+ { name = "pytest", marker = "extra == 'dev'", specifier = "==9.1.1" },
+ { name = "pytest-beartype-tests", marker = "extra == 'dev'", specifier = "==2026.4.26" },
+ { name = "pytest-cov", marker = "extra == 'dev'", specifier = "==7.1.0" },
+ { name = "pytest-regressions", marker = "extra == 'dev'", specifier = "==2.11.0" },
+ { name = "pyyaml", specifier = "==6.0.3" },
+ { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.2" },
+ { name = "setuptools-scm", marker = "extra == 'dev'", specifier = "==10.2.1" },
+ { name = "shellcheck-py", marker = "extra == 'dev'", specifier = "==0.11.0.1" },
+ { name = "shfmt-py", marker = "extra == 'dev'", specifier = "==4.0.0" },
+ { name = "sphinx", marker = "extra == 'dev'", specifier = "==9.1.0" },
+ { name = "sphinx-click", marker = "extra == 'dev'", specifier = "==6.2.0" },
+ { name = "sphinx-copybutton", marker = "extra == 'dev'", specifier = "==0.5.2" },
+ { name = "sphinx-inline-tabs", marker = "extra == 'dev'", specifier = "==2025.12.21.14" },
+ { name = "sphinx-lint", marker = "extra == 'dev'", specifier = "==1.0.2" },
+ { name = "sphinx-pyproject", marker = "extra == 'dev'", specifier = "==0.3.0" },
+ { name = "sphinx-substitution-extensions", marker = "extra == 'dev'", specifier = "==2026.8.5" },
+ { name = "sphinxcontrib-spelling", marker = "extra == 'dev'", specifier = "==8.0.2" },
+ { name = "sphinxcontrib-towncrier", marker = "extra == 'dev'", specifier = "==0.5.0a0" },
+ { name = "strict-kwargs", marker = "extra == 'dev'", specifier = "==2026.7.24" },
+ { name = "torch", marker = "extra == 'dev'", specifier = ">=2.5.1", index = "https://download.pytorch.org/whl/cpu" },
+ { name = "torchvision", marker = "extra == 'dev'", specifier = ">=0.20.1", index = "https://download.pytorch.org/whl/cpu" },
+ { name = "towncrier", marker = "extra == 'dev'", specifier = "==25.8.0" },
+ { name = "towncrier", marker = "extra == 'release'", specifier = "==25.8.0" },
+ { name = "ty", marker = "extra == 'dev'", specifier = "==0.0.70" },
+ { name = "types-pyyaml", marker = "extra == 'dev'", specifier = "==6.0.12.20260724" },
+ { name = "vale", marker = "extra == 'dev'", specifier = "==3.13.0.0" },
+ { name = "vulture", marker = "extra == 'dev'", specifier = "==2.16" },
+ { name = "vws-python", specifier = "==2026.8.14" },
+ { name = "vws-python-mock", marker = "extra == 'dev'", specifier = "==2026.8.14" },
+ { name = "vws-test-fixtures", marker = "extra == 'dev'", specifier = "==2023.3.5" },
+ { name = "yamlfix", marker = "extra == 'dev'", specifier = "==1.19.1" },
+ { name = "zizmor", marker = "extra == 'dev'", specifier = "==1.29.0" },
+]
+provides-extras = ["dev", "release"]
+
+[package.metadata.requires-dev]
+dev = []
+
+[[package]]
+name = "vws-python"
+version = "2026.8.14"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beartype" },
+ { name = "httpx" },
+ { name = "requests" },
+ { name = "urllib3" },
+ { name = "vws-auth-tools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3f/50/b65d70abb437082f723fc71f01003c0fa7a5ff4ee0aff990ec9878609a9a/vws_python-2026.8.14.tar.gz", hash = "sha256:bfcfc6196cbecb0e0c8f11fd7c358d53a8453a102078e8482ac6cc4c885239ca", size = 80716, upload-time = "2026-08-14T14:23:32.104Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/b0/f8b44fd494abaade88eb396c73c729546d1db1bb13792cd79eb363514f71/vws_python-2026.8.14-py3-none-any.whl", hash = "sha256:e80f9fb64f3190eef82bc7a1685fecb6670795a70169c2546088ccb68b135381", size = 47366, upload-time = "2026-08-14T14:23:30.846Z" },
+]
+
+[[package]]
+name = "vws-python-mock"
+version = "2026.8.14"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beartype" },
+ { name = "flask" },
+ { name = "httpx" },
+ { name = "numpy" },
+ { name = "opencv-contrib-python-headless" },
+ { name = "pillow" },
+ { name = "pydantic-settings" },
+ { name = "pyteenybrisque" },
+ { name = "requests" },
+ { name = "responses" },
+ { name = "respx" },
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+ { name = "vws-auth-tools" },
+ { name = "werkzeug" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ed/4d/4502c5cf6ecedf2121afe7c018f000bae8bc178f2722f4cc1073d14a01e8/vws_python_mock-2026.8.14.tar.gz", hash = "sha256:9a8fdf265cabd8539450066b09f07185a72decfec03cd67b6594e014724d6617", size = 340205, upload-time = "2026-08-14T11:03:14.899Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cd/e3/c853170d8e47479523add821f88acdb863f18f73babcd66a67337b74131b/vws_python_mock-2026.8.14-py3-none-any.whl", hash = "sha256:eff8f040bf465a4799cf94072a37807672a647e04ebcc6b314817252c2fdd1d1", size = 99382, upload-time = "2026-08-14T11:03:13.102Z" },
+]
+
+[[package]]
+name = "vws-test-fixtures"
+version = "2023.3.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pillow" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/f2/db267b21f32539d78aae06add3b31fa1e562a00a5b7d0aa1d0ee18aaadf9/vws-test-fixtures-2023.3.5.tar.gz", hash = "sha256:ba9baafb6fc8cd63338ee9c2b7c70876e6b33c6eb85edf7c4e8e88d9ab25467c", size = 61510, upload-time = "2023-03-05T17:26:04.019Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/40/e50b6c31637dfb41b13ffc41c799f210aa0fbb56853d32fee8cdfd8d9712/vws_test_fixtures-2023.3.5-py2.py3-none-any.whl", hash = "sha256:7f9f6a6be8e31bdd3ae4f290e7dea6637dc213a2da4c59d0cb6051c2054dee9f", size = 49364, upload-time = "2023-03-05T17:26:02.051Z" },
+]
+
+[[package]]
+name = "werkzeug"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
+]
+
+[[package]]
+name = "wheel-filename"
+version = "1.4.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/38/be/726dab762b770d0417e505c58e26d661aac1ec0c831e483cda4817ca2417/wheel_filename-1.4.2.tar.gz", hash = "sha256:87891c465dcbb40b40394a906f01a93214bdd51aa5d25e3a9a59cae62bc298fd", size = 7911, upload-time = "2024-12-01T13:03:16.012Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b4/0f/6e97a3bc38cdde32e3ec49f8c0903fe3559ec9ec9db181782f0bb4417717/wheel_filename-1.4.2-py3-none-any.whl", hash = "sha256:3fa599046443d4ca830d06e3d180cd0a675d5871af0a68daa5623318bb4d17e3", size = 6195, upload-time = "2024-12-01T13:03:00.536Z" },
+]
+
+[[package]]
+name = "win32-setctime"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
+]
+
+[[package]]
+name = "yamlfix"
+version = "1.19.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "maison" },
+ { name = "pydantic" },
+ { name = "ruyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/93/1d/b60d4411ff495de9b7598cc041e29c661e8e2f9d476a8a09bad1f54c1bce/yamlfix-1.19.1.tar.gz", hash = "sha256:05f6add13959637564f278e9237f6e201ff75e061a0a4cb9fc06fa95c3001a22", size = 39483, upload-time = "2025-12-18T09:57:23.179Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/30/c7/cba5941b7066f59dbddfe88bdc7154edbe5119bacb3814599997fbc2acac/yamlfix-1.19.1-py3-none-any.whl", hash = "sha256:b885fcf171a2eb59df83c219355bb17dd147675645e2756754372c0bd0b80ea5", size = 28393, upload-time = "2025-12-18T09:57:21.547Z" },
+]
+
+[[package]]
+name = "zizmor"
+version = "1.29.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c2/f8/f4e3fc0b316d5241b6d6968e8fb702e28446bc7d3c1e2b229f4caa6eacf2/zizmor-1.29.0.tar.gz", hash = "sha256:60e34e83c67064e0036989c7c525d13413e897aa4c4f683f1efb2048cdb28a47", size = 571865, upload-time = "2026-08-01T21:09:19.324Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/23/97/667ef4db0ca9225ee402c1b947b5b6f17fd234d72c15a71db150b7695c62/zizmor-1.29.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ea72f84d610643d57f96430c655a3780d0b874e477d32e14eae8e910f6cce1fd", size = 9037504, upload-time = "2026-08-01T21:08:56.752Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/d4/9fc7deaf75778e7516fa1d6c836377c3cb5d203dedc28899946b6f11ecdb/zizmor-1.29.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5aafe617d7b1e0c0c15d58fdf20495f360f74a791dfa136f76630b4cc06c2a34", size = 8654426, upload-time = "2026-08-01T21:08:59.212Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/f6/6db714fb0aa08aeec62eb9d6ad6a443a1f4ed50d4c0b789944ae55fb83e4/zizmor-1.29.0-py3-none-manylinux_2_24_aarch64.whl", hash = "sha256:67644ae8d6d0394204b9a488f7d86f0dd66fe562f4ba85fc53e6105a6bfc7b6a", size = 8918927, upload-time = "2026-08-01T21:09:01.46Z" },
+ { url = "https://files.pythonhosted.org/packages/15/40/a12edc0c0c0a0101c54dbb9099ff08f8de2fcb35c36cb706db3deb2c2728/zizmor-1.29.0-py3-none-manylinux_2_28_armv7l.whl", hash = "sha256:81e4093fed5c8a41d6ae7bb773085a9d2e6c0b0a0b560d46a9c76d69be0a07ed", size = 8500655, upload-time = "2026-08-01T21:09:03.677Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/f0/dfa67018b76bc4f2f50e265e8cbd1293833d1b1de5f3f02fbbb7487ae9c6/zizmor-1.29.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:587b99c2e1b34575c6c8565c2bfde415ca8bc0310f5589f19bc948c8dea10a20", size = 9351035, upload-time = "2026-08-01T21:09:06.16Z" },
+ { url = "https://files.pythonhosted.org/packages/90/1b/93cdd5a06984b394d90001f9778008a21689052904109080a09952626c99/zizmor-1.29.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:061600f23c46f2e400bcdef666c236de7e5c0b07dd6ca046daa001eb1514b909", size = 8941717, upload-time = "2026-08-01T21:09:08.861Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/f4/8d9e54405b477bc8e4b56c1a60123fca26c109ea6a762eea104fab32555e/zizmor-1.29.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:332546480be38aca95c149f835e0dcb7679ab5d74618a90c6ccb3fa6b8c7b99d", size = 8468289, upload-time = "2026-08-01T21:09:11.096Z" },
+ { url = "https://files.pythonhosted.org/packages/72/86/06d57ca830cc4653369c5aca22cccbf04c8c36ee84a67f351214e556bad8/zizmor-1.29.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a7462b9ab45d72a20ad5ab8193b430df8184c59e2bf46954ddd09496f2f00b45", size = 9446505, upload-time = "2026-08-01T21:09:13.38Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/f4/253d9a3538e0ea6f96a3bcd69839c0c5a816a00299620b58a10b5bd1df59/zizmor-1.29.0-py3-none-win32.whl", hash = "sha256:8c759e68cd866375030ca39e19e2de47a056b7be7288c1620e2d5b4c274f631f", size = 7655723, upload-time = "2026-08-01T21:09:15.758Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/2d/7919bc23475273ed8038a031fc24bc3f2005c78e608ce8df96746ef0fb98/zizmor-1.29.0-py3-none-win_amd64.whl", hash = "sha256:0fb85948ba5ffc7a8116eee36fe9cfc10167225c97bd2810e3378e66a9fd27c4", size = 8785519, upload-time = "2026-08-01T21:09:17.513Z" },
+]
diff --git a/zizmor.yml b/zizmor.yml
new file mode 100644
index 00000000..048ffa79
--- /dev/null
+++ b/zizmor.yml
@@ -0,0 +1,12 @@
+---
+rules:
+ unpinned-uses:
+ disable: true
+ cache-poisoning:
+ disable: true
+ dependabot-cooldown:
+ disable: true
+ superfluous-actions:
+ disable: true
+ artipacked:
+ disable: true