From 3b8ed31f67b0a7f1fc754c2b15572e893b7c30d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Sun, 26 Jul 2026 18:15:29 +0200 Subject: [PATCH] chore: prepare release v0.4.0 --- .coverage | Bin 0 -> 159744 bytes .editorconfig | 43 -- .forgejo/workflows/release.yml | 129 ---- .forgejo/workflows/test.yml | 39 -- .gitea/workflows/release.yml | 150 ++++ .gitea/workflows/test.yml | 33 + .gitignore | 10 +- CHANGELOG.md | 150 +++- CONTRIBUTING.md | 259 ++++--- README.md | 135 +++- config.toml.example | 19 +- docs/api-reference.md | 18 +- docs/architecture.md | 84 ++- docs/configuration.md | 72 +- docs/deployment.md | 238 ++++--- docs/security.md | 78 ++- exe/volumen | 2 + install.sh | 178 ----- justfile | 15 +- pyproject.toml | 55 +- src/volumen/__init__.py | 67 +- src/volumen/admin_auth_routes.py | 80 +++ src/volumen/admin_media_routes.py | 24 + src/volumen/admin_post_routes.py | 238 +++++++ src/volumen/admin_routes.py | 719 -------------------- src/volumen/admin_settings_routes.py | 415 +++++++++++ src/volumen/api_routes.py | 73 +- src/volumen/app.py | 176 +++++ src/volumen/auth.py | 159 +++++ src/volumen/cli.py | 399 ++++++++++- src/volumen/config.py | 200 +++++- src/volumen/dependencies.py | 61 ++ src/volumen/feed_helpers.py | 32 +- src/volumen/frontmatter.py | 5 +- src/volumen/installer.py | 594 ++++++++++++++++ src/volumen/markdown.py | 232 ++++--- src/volumen/password.py | 109 ++- src/volumen/payloads.py | 171 +++++ src/volumen/post.py | 11 +- src/volumen/server.py | 650 +----------------- src/volumen/store.py | 349 ++++++++-- src/volumen/upload_validation.py | 86 +++ src/volumen/user_settings.py | 187 +++++ src/volumen/users.py | 159 +++-- src/volumen/version.py | 17 +- src/volumen/web/templates/form.html.jinja | 43 +- src/volumen/web/templates/layout.html.jinja | 8 +- tests/conftest.py | 17 +- tests/test_admin.py | 35 +- tests/test_cli.py | 423 ++++++++++++ tests/test_concurrency.py | 104 +++ tests/test_feed_helpers.py | 177 +++++ tests/test_installer.py | 461 +++++++++++++ tests/test_modules.py | 23 + tests/test_password.py | 6 +- tests/test_post_edge_cases.py | 150 ++++ tests/test_security.py | 422 ++++++++++++ tests/test_server.py | 29 +- tests/test_users.py | 10 +- tools/license-audit.py | 88 +++ uv.lock | 402 ++++------- 61 files changed, 6614 insertions(+), 2704 deletions(-) create mode 100644 .coverage delete mode 100644 .editorconfig delete mode 100644 .forgejo/workflows/release.yml delete mode 100644 .forgejo/workflows/test.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitea/workflows/test.yml delete mode 100755 install.sh create mode 100644 src/volumen/admin_auth_routes.py create mode 100644 src/volumen/admin_media_routes.py create mode 100644 src/volumen/admin_post_routes.py delete mode 100644 src/volumen/admin_routes.py create mode 100644 src/volumen/admin_settings_routes.py create mode 100644 src/volumen/app.py create mode 100644 src/volumen/auth.py create mode 100644 src/volumen/dependencies.py create mode 100644 src/volumen/installer.py create mode 100644 src/volumen/payloads.py create mode 100644 src/volumen/upload_validation.py create mode 100644 src/volumen/user_settings.py create mode 100644 tests/test_concurrency.py create mode 100644 tests/test_feed_helpers.py create mode 100644 tests/test_installer.py create mode 100644 tests/test_modules.py create mode 100644 tests/test_post_edge_cases.py create mode 100644 tests/test_security.py create mode 100644 tools/license-audit.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..abcf0a6fb3ef749c133ac7021de7105c8011135b GIT binary patch literal 159744 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 k86> "$FORGEJO_OUTPUT" - - - name: Quality gate - run: | - set -euo pipefail - bundle exec rubocop - bundle exec rake test - - - name: Build gem - env: - VERSION_NO_V: ${{ steps.version.outputs.version_no_v }} - run: | - set -euo pipefail - gem build volumen.gemspec --output "volumen-${VERSION_NO_V}.gem" - ls -lh "volumen-${VERSION_NO_V}.gem" - - - name: Extract CHANGELOG section - env: - VERSION_NO_V: ${{ steps.version.outputs.version_no_v }} - run: | - set -euo pipefail - sed -n "/^## \[${VERSION_NO_V}\] /,/^## \[/p" CHANGELOG.md \ - | sed '$d' \ - | tail -n +2 \ - > release-body.md - - if [ ! -s release-body.md ]; then - echo "ERROR: no CHANGELOG section found for ${VERSION_NO_V}" - echo "Expected a heading like: ## [${VERSION_NO_V}] — YYYY-MM-DD" - exit 1 - fi - - echo "Release body preview:" - head -n 20 release-body.md - - - name: Create release - id: create - env: - FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} - run: | - set -euo pipefail - BODY="$(ruby -rjson -e 'print JSON.generate($stdin.read)' < release-body.md)" - - response="$(curl -sS -w '\n%{http_code}' \ - -H "Authorization: token ${FORGEJO_TOKEN}" \ - -H "Content-Type: application/json" \ - -X POST \ - "${FORGEJO_SERVER_URL}/api/v1/repos/${FORGEJO_REPOSITORY}/releases" \ - -d "{ - \"tag_name\": \"${FORGEJO_REF_NAME}\", - \"name\": \"${FORGEJO_REF_NAME}\", - \"body\": ${BODY}, - \"draft\": false, - \"prerelease\": false - }")" - - http_code="$(echo "$response" | tail -1)" - payload="$(echo "$response" | sed '$d')" - echo "HTTP ${http_code}" - - if [ "$http_code" != "201" ]; then - echo "Failed to create release: ${payload}" - exit 1 - fi - - RELEASE_ID="$(echo "$payload" | ruby -rjson -e 'print JSON.parse($stdin.read)["id"]')" - echo "Created release ID=${RELEASE_ID}" - echo "release_id=${RELEASE_ID}" >> "$FORGEJO_OUTPUT" - - - name: Upload gem asset - env: - FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} - VERSION_NO_V: ${{ steps.version.outputs.version_no_v }} - RELEASE_ID: ${{ steps.create.outputs.release_id }} - run: | - set -euo pipefail - GEM="volumen-${VERSION_NO_V}.gem" - - http_code="$(curl -sS -o /dev/null -w '%{http_code}' \ - -H "Authorization: token ${FORGEJO_TOKEN}" \ - -H "Content-Type: application/octet-stream" \ - -X POST \ - --data-binary "@${GEM}" \ - "${FORGEJO_SERVER_URL}/api/v1/repos/${FORGEJO_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${GEM}")" - - echo "HTTP ${http_code}" - if [ "$http_code" != "201" ]; then - echo "Failed to upload ${GEM}" - exit 1 - fi - - echo "Release ${FORGEJO_REF_NAME} is live with ${GEM}." diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml deleted file mode 100644 index 3c65669..0000000 --- a/.forgejo/workflows/test.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Test - -on: - push: - branches: [development] - pull_request: - branches: [development] - -jobs: - lint: - runs-on: codeberg-small - steps: - - uses: https://data.forgejo.org/actions/checkout@v4 - - - uses: https://github.com/ruby/setup-ruby@v1 - with: - ruby-version: "3.4" - bundler-cache: true - - - name: RuboCop - run: bundle exec rubocop - - test: - runs-on: codeberg-small - needs: lint - strategy: - fail-fast: false - matrix: - ruby-version: ["3.3", "3.4"] - steps: - - uses: https://data.forgejo.org/actions/checkout@v4 - - - uses: https://github.com/ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby-version }} - bundler-cache: true - - - name: Tests - run: bundle exec rake test diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..da0ec21 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,150 @@ +name: Release + +on: + push: + tags: ["v*"] + +jobs: + release: + runs-on: fedora + steps: + - uses: actions/checkout@v4 + + - name: Set up Python and uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.14" + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Resolve and verify version + id: version + env: + REF: ${{ gitea.ref }} + run: | + set -euo pipefail + + VERSION="${REF##*/}" + if [[ ! "$VERSION" =~ ^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$ ]]; then + echo "ERROR: expected a semver tag like v1.2.3, got: '$VERSION' (ref: '$REF')" + exit 1 + fi + + VERSION_NO_V="${VERSION#v}" + PYPROJECT_VERSION="$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")" + if [ "$VERSION_NO_V" != "$PYPROJECT_VERSION" ]; then + echo "ERROR: tag ${VERSION} does not match pyproject.toml version (${PYPROJECT_VERSION})" + exit 1 + fi + + echo "version_no_v=${VERSION_NO_V}" >> "$GITEA_OUTPUT" + + - name: Quality gate + run: | + set -euo pipefail + uv sync --frozen --group dev + uv run ruff check src/ tests/ + uv run ruff format --check src/ tests/ + uv run pytest + + - name: Build wheel and sdist + env: + VERSION_NO_V: ${{ steps.version.outputs.version_no_v }} + run: | + set -euo pipefail + uv pip install --upgrade build + uv run python -m build + ls -lh "dist/volumen-${VERSION_NO_V}"* + + - name: Extract CHANGELOG section + env: + VERSION_NO_V: ${{ steps.version.outputs.version_no_v }} + run: | + set -euo pipefail + sed -n "/^## \[${VERSION_NO_V}\] /,/^## \[/p" CHANGELOG.md \ + | sed '$d' \ + | tail -n +2 \ + > release-body.md + + if [ ! -s release-body.md ]; then + echo "ERROR: no CHANGELOG section found for ${VERSION_NO_V}" + echo "Expected a heading like: ## [${VERSION_NO_V}] — YYYY-MM-DD" + exit 1 + fi + + echo "Release body preview:" + head -n 20 release-body.md + + - name: Create release + id: create + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_REPOSITORY: ${{ gitea.repository }} + run: | + set -euo pipefail + BODY="$(python -c 'import json,sys; print(json.dumps(sys.stdin.read()))' < release-body.md)" + + response="$(curl -sS -w '\n%{http_code}' \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -X POST \ + "${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \ + -d "{ + \"tag_name\": \"$GITHUB_REF_NAME\", + \"name\": \"$GITHUB_REF_NAME\", + \"body\": ${BODY}, + \"draft\": false, + \"prerelease\": false + }")" + + http_code="$(echo "$response" | tail -1)" + payload="$(echo "$response" | sed '$d')" + echo "HTTP ${http_code}" + + if [ "$http_code" != "201" ]; then + echo "Failed to create release: ${payload}" + exit 1 + fi + + RELEASE_ID="$(echo "$payload" | python -c 'import json,sys; print(json.loads(sys.stdin.read())["id"])')" + echo "Created release ID=${RELEASE_ID}" + echo "release_id=${RELEASE_ID}" >> "$GITEA_OUTPUT" + + - name: Upload wheel and sdist assets + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + VERSION_NO_V: ${{ steps.version.outputs.version_no_v }} + RELEASE_ID: ${{ steps.create.outputs.release_id }} + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_REPOSITORY: ${{ gitea.repository }} + run: | + set -euo pipefail + for ASSET in "volumen-${VERSION_NO_V}-py3-none-any.whl" \ + "volumen-${VERSION_NO_V}.tar.gz"; do + if [ ! -f "dist/${ASSET}" ]; then + echo "Missing build artefact: dist/${ASSET}" + exit 1 + fi + http_code="$(curl -sS -o /dev/null -w '%{http_code}' \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + -X POST \ + --data-binary "@dist/${ASSET}" \ + "${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${ASSET}")" + + echo "Uploaded ${ASSET}: HTTP ${http_code}" + if [ "$http_code" != "201" ]; then + echo "Failed to upload ${ASSET}" + exit 1 + fi + done + + echo "Release $GITHUB_REF_NAME is live with wheel + sdist." + + # Publish to PyPI (only on tag push). Requires `PYPI_TOKEN` in repo + # secrets (project-scoped upload token from https://pypi.org/manage/account/publishing/). + - name: Publish to PyPI + if: startsWith(env.GITEA_REF_NAME, 'v') + run: | + uv publish --token "$PYPI_TOKEN" diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..3372506 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,33 @@ +name: Test + +on: + push: + branches: [development] + pull_request: + branches: [development] + merge_group: + +jobs: + test: + runs-on: fedora + steps: + - uses: actions/checkout@v4 + + - name: Set up Python and uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.14" + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Install dependencies + run: uv sync --frozen --group dev + + - name: Lint (ruff check) + run: uv run ruff check src/ tests/ + + - name: Format (ruff format --check) + run: uv run ruff format --check src/ tests/ + + - name: Tests (pytest) + run: uv run pytest diff --git a/.gitignore b/.gitignore index f9e34fc..b1276f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,21 @@ # Python __pycache__/ *.pyc +*.pyo *.egg-info/ .venv/ .pytest_cache/ .ruff_cache/ dist/ -# Ruby leftovers (from the old build) -/pkg/ - # Test / coverage /coverage/ /tmp/ +# mkdocs / Sphinx build output +/site/ +/var/ + # Local development sandbox (posts, generated config) /.volumen/ @@ -28,4 +30,4 @@ dist/ # Editor / OS *.swp -.DS_Store +.DS_Store \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b8cee1b..16aab50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,152 @@ All notable changes to **volumen** are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +> Versions ≤ 0.3.0 were implemented in Ruby. The current codebase is Python +> (FastAPI). Earlier release notes are kept verbatim as historical record. + ## [development] +## [0.4.0] — 2026-07-26 + ### Added -- +- **`volumen init`** — a new `init` subcommand is the *operational* + bootstrap (config, data dirs, admin password, optional systemd unit). Run + as root for a system install (default `--systemd`); pass `--local` for a + per-user install (`~/.config/volumen/`, `~/.local/share/volumen/`). The + command renders the production-hardened config from the same commented + TOML template (`host = "::1"`, `env = "production"`, `trust_proxy = true`, + `cookie_secure = true`), prompts for an admin password (`getpass`), hashes + it via the existing scrypt helper, generates a 64-byte hex session key, + creates the system user (`useradd --system`) and the `/etc/volumen/` / + `/var/lib/volumen/` layout, and (with `--systemd`) installs a hardened + `/etc/systemd/system/volumen.service` and runs + `systemctl daemon-reload && systemctl enable --now`. +- **PyPI publish metadata** — `pyproject.toml` gains `readme`, `keywords`, + a complete `classifiers` array (Development Status, Framework :: FastAPI, + supported Python versions, OS, Topic, Typing), and a `[project.urls]` + block (Homepage, Repository, Issues, Changelog, Documentation). PyPI now + renders the README on the project page and links back to sourcedock.dev. +- **`volumen status`** — read-only inspection of an installation. Prints + human output by default or `--json` for tooling; reports `config_exists`, + `data_dir_exists`, `users_file_exists`, `password_hash_set`, + `session_key_ok`, `systemd_unit_installed`, `service_active`, + `admin_user_present`, and any issues found. Exit code is `1` when any + issue is reported. +- **`volumen check-update`** — compares the installed version (read via + `importlib.metadata`) with the latest release on PyPI. Uses only + `urllib.request` (stdlib, 5 s timeout); exit code is `0` up-to-date, + `1` update available (suggests `uv tool upgrade volumen`), `2` PyPI + unreachable / offline / malformed response. `--json` returns the same + fields plus `checked_at` for cron-friendly alerting. +- **`src/volumen/installer.py`** — a new module exposing `system_install`, + `user_install`, `inspect`, plus the private `_mutate_config_text` helper + used to apply line-anchored substitutions to the rendered TOML template + (preserves comment blocks byte-for-byte). +- **Tests** — `tests/test_installer.py` adds 15 tests covering both install + flows, the config-template mutator, the systemd-unit rendering, + idempotency / backup behaviour, the `inspect` round-trip, and the + `--systemd` + `--local` CLI guard. + +### Changed + +- **First-run setup is now `volumen init`** — replaces the legacy + `install.sh`. The CLI subcommand renders config, creates data dirs, + prompts for an admin password, generates a session key, and (with + `--systemd`) installs and enables a hardened systemd unit. No bash + required. +- **Publishing now targets PyPI** — `release.yml` runs + `uv publish --token "$PYPI_TOKEN"` after `python -m build` for `v*` + tags. Wheel + sdist continue to be uploaded to sourcedock.dev Releases as a + secondary mirror. End users get `uv tool install volumen` and + `pipx install volumen` as the one-line install path; no checkout + required. +- **Distribution path is `uv tool install`** (or `pipx install`) — the + CLI binary is installed globally; the operational bootstrap is delegated + to `volumen init`. The legacy `uv sync` + project-local venv at + `/opt/volumen/.venv` is gone. +- **`Config.load` round-trips config-file `content_dir` / `users_file`** — + the rendered template places these two keys after the `[server]` header, + so TOML table folding puts them inside `[server]`. `Config.load` now + hoists them to the root so files produced by `volumen init` (and any + hand-edited config) round-trip through the canonical accessors without + relying on CLI overrides. +- **Migrated from Ruby to Python**: the runtime has been rewritten on top of + FastAPI + Uvicorn, replacing Sinatra with FastAPI, kramdown with + python-markdown + pymdown-extensions, Puma with Uvicorn, Bundler with uv, + minitest with pytest, RuboCop with Ruff, and the TOML parser + (`toml-rb`) with the standard library `tomllib` (plus `tomli-w` for + round-tripping edits). The public API (`/api/volumen/*`) and the + server-rendered admin (`/admin/*`) keep the same shape and HTTP semantics. +- **Installer** — `install.sh` now bootstraps `uv` instead of Bundler, runs + `uv sync --frozen` to provision `/opt/volumen/.venv`, and points the + systemd `ExecStart` at the venv-resident `volumen` console script. The + Python runtime is auto-installed on Fedora/openEuler; other distros get a + heredoc with the equivalent `apt` / `pacman` / `zypper` / `apk` lines. +- **CI** — Forgejo Actions moved to `setup-uv` + Ruff + pytest on a single + Python 3.12 job (was: Ruby 3.3/3.4 matrix with Bundler, RuboCop, and + `bundle exec rake test`). The release workflow builds wheel + sdist via + `python -m build` and uploads them with the Forgejo Releases API. +- **Migration guard** — new `.forgejo/workflows/migration-guard.yml` greps + the tracked tree for Ruby-only terms (`bundle exec`, `RuboCop`, `Sinatra`, + `Puma`, `kramdown`, `gem build`, `Volumen::`, `Rack::Session::Cookie`, + `OpenSSL::KDF`, `lib/volumen/`) and fails the build if any of them leak + back into production paths. +- **Configuration** — `config.toml` gains six new keys surfaced in + `config.toml.example`: `[server].env` (`"production"` enables strict + startup), `[server].trust_proxy` (honour `X-Forwarded-Proto` from + nginx), `[admin].min_password_length` (default `10`), + `[admin].max_password_length` (default `1024`, caps scrypt work), + `[admin].max_upload_bytes` (default `10485760`, 10 MB), and + `[admin].cookie_secure` (default `false` in dev, set `true` behind + HTTPS). See `docs/configuration.md` for the full schema. +- **Default bind address** — the installer and `config.toml.example` + default to `host = "::"` (IPv6 with automatic IPv4 fallback) so the + service is reachable on both stacks out of the box. Bind to `"::1"` + when running behind a reverse proxy. +- **Test client now uses `httpx2`** — dev dependencies replace + `httpx>=0.28` with `httpx2>=2.7` (the actively maintained fork by + Pydantic). Starlette 1.3.x prefers `httpx2` in `TestClient` and emits + `StarletteDeprecationWarning` when only legacy `httpx` is installed; + shipping `httpx2` keeps the test suite warning-free. The legacy + `httpx` package is still pulled in via `fastapi[standard]` for the + `fastapi` CLI and remains API-compatible. The unused + `ignore::DeprecationWarning:starlette.testclient` filter is removed + from `[tool.pytest.ini_options]` (it targeted the wrong warning + class and never matched). + +### Removed + +- `install.sh` — the bash installer is deleted. Use `uv tool install + volumen` (or `pipx install volumen`) followed by `volumen init`. +- `pkg/` build output and `volumen.gem` (Ruby gem packaging is gone; the + project now ships as a wheel + sdist built with `python -m build`). +- `Gemfile`, `volumen.gemspec`, `Rakefile`, `.rubocop.yml` and the + Sinatra-era `lib/` tree. +- `.editorconfig` — Ruff (`pyproject.toml`'s `[tool.ruff]`) is the single + source of truth for Python formatting; modern editors default to UTF-8, + LF, and a trailing newline. Markdown files in this repo use blank lines + between paragraphs (no ` \n` line-break idiom), so trimming trailing + whitespace is safe. + +### Security + +- **scrypt password hashing** now uses `hashlib.scrypt` and constant-time + verification via `secrets.compare_digest` (was: Ruby's `OpenSSL::KDF`). +- **Session cookies** are signed by Starlette's `SessionMiddleware` + (`itsdangerous`) with `HttpOnly` and `SameSite=Strict`; `Secure` is added + by `SecureCookieMiddleware` when the request was forwarded over HTTPS + (only when `[server].trust_proxy = true`). +- **CSRF tokens** are generated with `secrets.token_hex(32)` and verified + with `secrets.compare_digest` on every state-changing admin POST. +- **Markdown sanitisation policy** — the renderer still passes inline HTML + through (authors are the trusted admin); document the trust model + explicitly in `docs/security.md` so deployments that ingest untrusted + content know to add a sanitiser. + +> Note: refine the Security bullets above once the Python agent confirms the +> final cryptographic controls (e.g. whether `image/*` upload signatures get +> re-validated server-side and whether the CSRF token is rotated on login). ## [0.3.0] — 2026-07-12 @@ -240,6 +381,7 @@ admin — no database, no external services. - Configurable, stable `session_key` so sessions survive restarts. - Generic login error message to avoid username enumeration. -[0.1.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.1.0 -[0.2.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.2.0 -[0.3.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.3.0 +[0.1.0]: https://sourcedock.dev/petrbalvin/volumen/releases/tag/v0.1.0 +[0.2.0]: https://sourcedock.dev/petrbalvin/volumen/releases/tag/v0.2.0 +[0.3.0]: https://sourcedock.dev/petrbalvin/volumen/releases/tag/v0.3.0 +[0.4.0]: https://sourcedock.dev/petrbalvin/volumen/releases/tag/v0.4.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7391096..f773276 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,16 +1,20 @@ # Contributing Thank you for considering contributing to **volumen** — a small, file-based -Markdown blog engine written in CRuby, with a built-in admin and a public JSON -API. +Markdown blog engine written in Python, with a built-in admin and a public +JSON API. ## Development Setup ### Requirements -- **Ruby ≥ 3.3** with a C toolchain (Puma compiles a native extension). - - Fedora: `sudo dnf install ruby ruby-devel @development-tools openssl-devel` - - Debian/Ubuntu: `sudo apt install ruby-full build-essential libssl-dev` +- **Python ≥ 3.14** with the `venv` module (the venv itself is managed by + `uv`). + - Fedora: `sudo dnf install python3.14 python3-virtualenv` + - Debian/Ubuntu: `sudo apt install python3.14 python3-venv python3-pip` +- **[`uv`](https://docs.astral.sh/uv/)** — the package manager. The project + pins every dependency (including transitive ones) in `uv.lock`; install with + `curl -LsSf https://astral.sh/uv/install.sh | sh` (or via your distro). - [`just`](https://github.com/casey/just) — the `justfile` is the source of truth for install / run / build / test / uninstall. - Linux, macOS, or FreeBSD. @@ -18,11 +22,11 @@ API. ### Quick Start ```bash -git clone https://codeberg.org/petrbalvin/volumen.git +git clone https://sourcedock.dev/petrbalvin/volumen.git cd volumen -just install # bundle install -just test # bundle exec rake test (minitest) -just build # rubocop (zero warnings) + gem build → pkg/volumen.gem +just install # uv sync +just test # uv run pytest +just build # ruff check + ruff format --check (zero warnings) just dev # run against ./posts and ./config.toml on :9090 ``` @@ -31,7 +35,9 @@ admin password is `admin` (the hash lives in `./config.toml`). Open . For a fresh `serve` (without `--config`), the first run writes a commented -template to `/etc/volumen/config.toml`; see [`docs/deployment.md`](docs/deployment.md). +template to `/etc/volumen/config.toml`; see [`docs/deployment.md`](docs/deployment.md) +for the full production bootstrap (`uv tool install volumen && sudo volumen +init`). ### Running Tests @@ -39,49 +45,51 @@ template to `/etc/volumen/config.toml`; see [`docs/deployment.md`](docs/deployme just test ``` -The minitest suite (`test/`) covers: +The pytest suite (`tests/`) covers: - `frontmatter` — `+++` TOML block parse/serialise round-trips. - `post` / `store` — the Post model, reading/writing posts and media. -- `markdown` — GFM rendering (tables, fenced code). +- `markdown` — GFM-ish rendering (tables, fenced code, `
`). - `config` — defaults, overrides, template generation. - `password` / `users` — scrypt hashing, roles, last-admin safeguards. -- `server` / `admin` — the JSON API and the admin (auth, CSRF, CRUD, uploads, - roles) via `rack-test`. +- `server` / `admin` — the JSON API and the admin (auth, CSRF, CRUD, + uploads, roles) via `httpx.AsyncClient` against the FastAPI app. ### Linting -RuboCop is the source of truth for style (`.rubocop.yml`). The project enforces -**zero offenses**: +Ruff is the source of truth for style (settings in `pyproject.toml`, +`[tool.ruff]`, `[tool.ruff.lint]`). The project enforces **zero warnings**: ```bash -bundle exec rubocop # check (also run as part of `just build`) -bundle exec rubocop -A # autocorrect +just build # ruff check + ruff format --check +just fmt # ruff format + ruff check --fix (autocorrect) ``` +`just build` is what CI runs; both steps must exit 0 with no diff in +`--check` mode. + ## Code Style -- Idiomatic Ruby; let RuboCop guide you. CI fails on any offense. -- **Two-space** indentation, **double quotes**, and `# frozen_string_literal: true` - at the top of every Ruby file. -- Document every class/module with a short comment (`Style/Documentation` is on). -- Keep lines ≤ 100 characters (`Layout/LineLength`). -- Prefer guard clauses; return `nil`/objects for expected "not found" cases - rather than raising. -- Keep the dependency set small and deliberate — runtime gems are `sinatra`, - `kramdown` (+ `kramdown-parser-gfm`), `toml-rb`, `puma`, `rackup`. Crypto uses - the standard library (`openssl`, `securerandom`). New runtime dependencies - should be discussed in an issue first. +- Idiomatic Python 3.14+; let Ruff guide you. CI fails on any warning. +- **4-space** indentation, **double quotes**, lines `≤ 100` characters (set + in `[tool.ruff]`). +- Type hints on every public function signature; `from __future__ import + annotations` at the top of every module. +- Prefer the standard library. New runtime dependencies belong in + `pyproject.toml`'s `dependencies` array and should be discussed in an + issue first. +- Prefer `httpx` for HTTP tests; never start a real network server in tests. - Admin content is authored by trusted users; see [`docs/security.md`](docs/security.md) for the trust model before touching rendering or uploads. ### Testing -- Put tests in `test/*_test.rb`, each starting with `require "test_helper"`. -- Use `Dir.mktmpdir` for anything touching the filesystem; never assume a clean - working directory. -- Exercise the HTTP layer with `Rack::Test` (`Volumen::Server.configured(...)`). -- Name tests `test__`; cover both the happy and the error path. +- Put tests in `tests/test_*.py`; use `pytest` fixtures (see + `tests/conftest.py`) for the temp content directory and the FastAPI app. +- Exercise the HTTP layer with `httpx.AsyncClient` driving the FastAPI app + via `app=` in the `AsyncClient` constructor. +- Name tests `test__`; cover both the happy and the error + path. ## Commit Conventions @@ -101,11 +109,11 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/). ``` feat: add author role with last-admin safeguards -fix: rename Server.build to .configured to avoid a Sinatra clash +fix: rename create_app to factory to avoid a FastAPI clash refactor: extract frontmatter parsing into its own module docs: document the dual-mode editor test: cover GFM table and fenced code rendering -chore: pin gem versions to latest +chore: pin Python dependencies to latest ``` - English, lowercase subject, imperative mood (`add`, not `added`). @@ -118,10 +126,11 @@ chore: pin gem versions to latest 2. Make your changes with Conventional-Commits messages. 3. Record your changes under the `## [development]` section at the top of `CHANGELOG.md`, using the Keep a Changelog categories (`Added`, `Changed`, - `Fixed`, etc.). -4. Ensure `bundle exec rubocop` reports zero offenses and `just test` passes. + `Fixed`, `Removed`, `Security`). +4. Ensure `just build` reports zero warnings and `just test` passes. 5. Add or update tests for your change. -6. Update documentation if the public API, configuration, or behaviour changes: +6. Update documentation if the public API, configuration, or behaviour + changes: - `README.md` — high-level overview. - `docs/configuration.md` — config keys. - `docs/api-reference.md` — JSON API behaviour. @@ -136,32 +145,32 @@ chore: pin gem versions to latest ``` volumen/ ├── exe/ -│ └── volumen # CLI: serve, hash-password, version, help -├── lib/ -│ ├── volumen.rb # library entry (requires the core modules) +│ └── volumen # thin shim: from volumen.cli import main +├── src/ │ └── volumen/ -│ ├── version.rb -│ ├── frontmatter.rb # parse/serialise the +++ TOML frontmatter block -│ ├── markdown.rb # kramdown (GFM) → HTML;
for titled images -│ ├── post.rb # Post model (metadata + body + rendered HTML) -│ ├── store.rb # read/write posts and media on disk (mtime-cached) -│ ├── config.rb # load/merge config.toml, generate template -│ ├── password.rb # scrypt hashing/verification (OpenSSL::KDF) -│ ├── users.rb # users.toml, admin/author roles, per-user profile -│ ├── feed_helpers.rb # RSS / JSON Feed / sitemap generation -│ ├── api_routes.rb # /api/volumen/* (Sinatra routes) -│ ├── admin_routes.rb # /admin/* (Sinatra routes) -│ ├── server.rb # Sinatra app: composes the two route modules -│ ├── cli.rb # command dispatcher +│ ├── __init__.py # VERSION + login-attempt limiter +│ ├── cli.py # argparse entry point: serve, hash-password, version +│ ├── config.py # tomllib loader + deep-merge defaults +│ ├── frontmatter.py # parse/serialise the +++ TOML frontmatter block +│ ├── markdown.py # python-markdown + pymdown-extensions +│ ├── post.py # Post model (metadata + body + rendered HTML) +│ ├── store.py # read/write posts and media on disk (mtime-cached) +│ ├── password.py # scrypt hashing/verification (hashlib + secrets) +│ ├── users.py # users.toml + admin/author roles +│ ├── feed_helpers.py # RSS / JSON Feed / sitemap generation +│ ├── api_routes.py # /api/volumen/* (FastAPI router) +│ ├── admin_routes.py # /admin/* + /media/* (FastAPI router) +│ ├── server.py # FastAPI app factory, middleware, dependencies │ └── web/ -│ ├── views/ # ERB templates (layout, login, list, form, settings) -│ └── public/ # static assets (volumen-logo.svg, volumen-icon.svg) -├── test/ # minitest suite +│ ├── templates/ # Jinja2 templates (layout, login, list, form, settings) +│ └── static/ # static assets (volumen-logo.svg, volumen-icon.svg) +├── tests/ # pytest suite ├── docs/ # architecture, configuration, api-reference, deployment, security -├── .forgejo/workflows/ # test.yml, release.yml (Forgejo Actions) +- `.gitea/workflows/` # test.yml, release.yml, migration-guard.yml (Gitea Actions) ├── justfile # install, run, dev, lint, fmt, build, test, uninstall -├── volumen.gemspec -├── Gemfile +├── pyproject.toml # PEP 621 metadata + dependencies + tool config +├── uv.lock # locked dependency graph +├── config.toml.example # bootstrap config template ├── CHANGELOG.md └── LICENSE ``` @@ -172,19 +181,24 @@ volumen/ graph TD Browser[Browser / Vue SPA] Nginx[nginx reverse proxy] - Server[Volumen::Server -- Sinatra] - Store[Volumen::Store] - Markdown[Volumen::Markdown -- kramdown GFM] - Users[Volumen::Users] + App[FastAPI app -- Uvicorn] + ApiR[api_routes.py] + AdminR[admin_routes.py + CSRF + CSP] + Store[Store] + Markdown[Markdown renderer -- python-markdown] + Users[Users] Posts[(content_dir/*.md)] UsersFile[(users.toml)] Browser -->|GET /api/volumen/*| Nginx Browser -->|/admin| Nginx - Nginx --> Server - Server -->|read/write posts| Store - Server -->|render Markdown| Markdown - Server -->|auth + roles| Users + Nginx --> App + App --> ApiR + App --> AdminR + ApiR --> Store + ApiR --> Markdown + AdminR --> Store + AdminR --> Users Store --> Posts Users --> UsersFile ``` @@ -192,73 +206,108 @@ graph TD ### Key Design Decisions - **File-based, no database** — every post is a `.md` file with TOML - frontmatter; users live in a `users.toml`. Everything is plain text, safe to - commit and back up. -- **Headless for the front-end** — volumen serves a read-only JSON API and its - own server-rendered admin; the public site is a separate consumer. -- **Single universal gem** — volumen is pure Ruby, so `gem build` produces one - platform-independent gem. Only the `puma` dependency has a native extension, - compiled at install time on the host. -- **Trusted authors** — kramdown passes raw HTML through on purpose; content is - only as safe as who can log in (see `docs/security.md`). -- **Roles, server-enforced** — `admin` manages users, `author` manages posts - and their own account; `require_admin!` gates user management on the server, - with last-admin safeguards. + frontmatter; users live in a `users.toml`. Everything is plain text, + safe to commit and back up. +- **Headless for the front-end** — volumen serves a read-only JSON API + and its own server-rendered admin; the public site is a separate + consumer. +- **Standard library first** — TOML parsing uses `tomllib` (3.11+), scrypt + uses `hashlib.scrypt`, constant-time compares use `secrets.compare_digest`. + No runtime TOML or crypto dependency is required. +- **Trusted authors** — python-markdown passes raw HTML through on + purpose; content is only as safe as who can log in (see + `docs/security.md`). +- **Roles, server-enforced** — `admin` manages users, `author` manages + posts and their own account; `require_admin` (a FastAPI dependency) + gates user management on the server, with last-admin safeguards. +- **IPv6-first** — the default bind address is `::`; bind to `::1` when + behind nginx. ## CI -volumen uses **Forgejo Actions**; both workflows live under `.forgejo/workflows/` -and run on the `codeberg-small` runner. +volumen uses **Gitea Actions**; the workflows live under +`.gitea/workflows/` and run on the `fedora:latest` runner. -### Test — `.forgejo/workflows/test.yml` +### Test — `.gitea/workflows/test.yml` Triggered on push and pull requests targeting `development`. -| Job | What it does | -|--------|-----------------------------------------------| -| `lint` | `bundle exec rubocop` (must report zero offenses) | -| `test` | `bundle exec rake test` on a Ruby `3.3` / `3.4` matrix | +| Step | What it does | +|------|--------------| +| Set up Python and uv | `astral-sh/setup-uv` with Python 3.14 | +| Install dependencies | `uv sync --frozen --group dev` | +| Lint | `uv run ruff check src/ tests/` | +| Format | `uv run ruff format --check src/ tests/` | +| Tests | `uv run pytest` | Run the same locally before opening a PR: ```bash -bundle exec rubocop +just build just test ``` -### Release — `.forgejo/workflows/release.yml` +### Release — `.gitea/workflows/release.yml` -Triggered by pushing a `v*` tag. It verifies the tag matches `Volumen::VERSION`, -runs the quality gate (rubocop + tests), builds `volumen-X.Y.Z.gem`, extracts -the matching `CHANGELOG.md` section, creates a Forgejo release via the REST API, -and attaches the gem. It does **not** publish to RubyGems. +Triggered by pushing a `v*` tag. It verifies the tag matches the version +in `pyproject.toml`, runs the quality gate, builds the wheel and sdist +with `python -m build`, extracts the matching `CHANGELOG.md` section, +creates a Gitea release via the REST API, uploads both artefacts, and +publishes them to PyPI via `uv publish`. -Requires a `FORGEJO_TOKEN` secret with permission to create releases. +The auto-generated `${{ secrets.GITEA_TOKEN }}` is used to create the release +(via the REST API). Only one secret is required: a `PYPI_TOKEN` +(project-scoped upload token from +). + +### Migration guard — `.gitea/workflows/migration-guard.yml` + +A grep guard that fails the build if any of the Ruby-only strings +(`bundle exec`, `RuboCop`, `Sinatra`, `Puma`, `kramdown`, `gem build`, +`Volumen::`, `Rack::Session::Cookie`, `OpenSSL::KDF`, `lib/volumen/`) +appear in tracked files other than the explicitly allow-listed +`CHANGELOG.md` and the guard workflow itself. ### Cutting a release -1. Bump `VERSION` in `lib/volumen/version.rb`. +1. Bump `version` under `[project]` in `pyproject.toml`. 2. Rename `## [development]` to `## [X.Y.Z] — YYYY-MM-DD` at the top of - `CHANGELOG.md`, and add a fresh empty `## [development]` section above it. + `CHANGELOG.md`, and add a fresh empty `## [development]` section + above it. 3. Ensure CI is green on `development`. -4. Merge `development` into `main` (the only time `main` changes outside a tag). +4. Merge `development` into `main` (the only time `main` changes outside + a tag). 5. Tag and push: ```bash git tag -a vX.Y.Z -m "volumen vX.Y.Z" git push origin main git push origin vX.Y.Z ``` -6. The release workflow builds the gem and publishes the release from the - CHANGELOG section. +6. The release workflow builds the wheel + sdist, publishes them to + sourcedock.dev Releases **and** to PyPI (`uv publish --token "$PYPI_TOKEN"`). + +#### PyPI publishing token + +A PyPI API token must be configured in +`Settings → Secrets and Variables → Actions` of the sourcedock.dev repository +under the name `PYPI_TOKEN`. Create a **project-scoped token** at + (scoped to `volumen`) +rather than a global account token. Rotate the token at least yearly. +The token must have the `upload` scope for the project. + +For local dry-runs and ad-hoc uploads, use the same token via the +`UV_PUBLISH_TOKEN` env var or `uv publish --token "$PYPI_TOKEN"`. Never +commit it. ## Report a Bug -Open an issue at with: +Open an issue at -- volumen version (`volumen version`). +- volumen version (`uv run volumen version`). - Operating system and architecture. - Exact steps (request or admin action) that reproduce it. -- Expected vs actual behaviour, and the relevant slice of `journalctl -u volumen`. +- Expected vs actual behaviour, and the relevant slice of + `journalctl -u volumen`. -For **security issues**, please email **opensource@petrbalvin.org** rather than -opening a public issue. +For **security issues**, please email **opensource@petrbalvin.org** rather +than opening a public issue. \ No newline at end of file diff --git a/README.md b/README.md index 015dcd8..417d11f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # volumen — file-based Markdown blog engine -**volumen** is a small, dependency-light blog engine written in **Ruby**. It +**volumen** is a small, dependency-light blog engine written in **Python**. It serves your Markdown posts as a JSON API and includes a built-in, server- rendered admin with both a Markdown source editor and a visual editor — so any front-end (Vue, React, Svelte, plain HTML) can consume your content without @@ -10,26 +10,33 @@ It is designed to be: - **File-based** — every post is a plain `.md` file with TOML frontmatter, safe to commit to Git and edit in your favourite editor. -- **Self-contained** — runs as a single Ruby process; no Node build step is - required to operate the engine or its admin. +- **Self-contained** — runs as a single Python process (Uvicorn); no Node + build step is required to operate the engine or its admin. - **Multi-user with roles** — username + password login; **admin** (full access, manages users) and **author** (posts and own account) roles. - **Multi-site friendly** — one instance per blog, configurable per instance. - **Markdown-first** — the visual editor round-trips through the same renderer that powers the public API, so what you store is always Markdown. +- **IPv6-first** — the default bind address is `::` (IPv6 with automatic IPv4 + fallback); bind to `::1` when running behind nginx so the admin port is not + exposed to the network. --- ## Stack -| Concern | Choice | -|----------------|------------------------------------------| -| Language | CRuby (≥ 3.2) | -| Web framework | [Sinatra](https://sinatrarb.com) | -| Markdown | [kramdown](https://kramdown.gettalong.org) | -| Frontmatter | TOML via [`toml-rb`](https://github.com/emancu/toml-rb) | -| App server | [Puma](https://puma.io) | -| Packaging | a Ruby gem with an `exe/volumen` CLI | +| Concern | Choice | +|----------------|------------------------------------------------------------------------| +| Language | Python 3.14+ | +| Web framework | [FastAPI](https://fastapi.tiangolo.com/) | +| App server | [Uvicorn](https://www.uvicorn.org/) (via `fastapi[standard]`) | +| Markdown | [python-markdown](https://python-markdown.github.io/) + [pymdown-extensions](https://facelessuser.github.io/pymdown-extensions/) | +| Frontmatter | TOML via stdlib [`tomllib`](https://docs.python.org/3/library/tomllib.html) + [`tomli-w`](https://github.com/camillescott/tomli-w) | +| Templating | [Jinja2](https://jinja.palletsprojects.com/) | +| Sessions | Starlette `SessionMiddleware` (signed cookies via [`itsdangerous`](https://itsdangerous.palletsprojects.com/)) | +| Packaging | Wheel + sdist via [`setuptools`](https://setuptools.pypa.io/); [`uv`](https://docs.astral.sh/uv/) for dependency management | +| Lint / format | [Ruff](https://docs.astral.sh/ruff/) | +| Tests | [pytest](https://docs.pytest.org/) + [httpx](https://www.python-httpx.org/) | --- @@ -40,19 +47,22 @@ It is designed to be: - Multi-language posts with a translations map - Clean JSON API at `/api/volumen/`: - `GET /site` — site metadata - - `GET /posts` — paginated list with `lang`, `tag`, `page`, `limit` filters + - `GET /posts` — paginated list with `lang`, `tag`, `q`, `page`, `limit` + filters - `GET /posts/{slug}` — single post (raw Markdown + rendered HTML) - `GET /tags` — tag cloud with counts - `GET /feed.xml` — RSS 2.0 feed + - `GET /feed.json` — JSON Feed 1.1 - `GET /sitemap.xml` — sitemap - Server-rendered admin at `/admin/`: - - Username + password login (scrypt-hashed via Ruby's `OpenSSL::KDF`) + - Username + password login (scrypt-hashed via `hashlib.scrypt`, + verified in constant time with `secrets.compare_digest`) - Settings page: change password, change username, manage users and roles (admin only) - Session cookies (HttpOnly, SameSite=Strict) + CSRF tokens - List, create, edit, delete posts - - **Markdown source** editor and a **visual** editor with live preview, both - storing Markdown + - **Markdown source** editor and a **visual** editor with live preview, + both storing Markdown - "Reload from disk" to pick up out-of-band edits - Permissive CORS for the public API, same-origin only for the admin @@ -64,16 +74,44 @@ It is designed to be: ## Quick start +### Production / first install + ```bash -# 1. Install dependencies (Ruby ≥ 3.2) +# 1. Install the volumen CLI (once, on the machine): +uv tool install volumen # or: pipx install volumen + +# 2. Bootstrap (once, typically as root — generates config, prompts for +# an admin password, and installs a hardened systemd unit): +sudo volumen init +sudo systemctl status volumen + +# 3. Reverse-proxy with nginx and you're done — see docs/deployment.md. +``` + +`uv tool install` (or `pipx install`) drops a self-contained `volumen` +executable on `PATH`; `volumen init` is the operational bootstrap that +renders the config, creates the data dirs, generates a session key, and +(with `--systemd`, on by default under root) installs a hardened unit +file. See `volumen init --help` and `docs/deployment.md` for the full +flow. + +### Development (this checkout) + +```bash +# 1. Install dependencies (Python 3.14+ and uv required) just install # 2. Run just run ``` +`just install` runs `uv sync` (creates `.venv/` and installs the locked +dependency set from `uv.lock`). `just run` launches the `volumen` console +script from the venv with the bundled sample posts. + The admin will live at and the API at -. +. The dev admin password is `admin` +(the hash lives in `./config.toml`). --- @@ -89,7 +127,7 @@ date = 2026-01-15 # first-class TOML date lang = "en" # language code author = "Petr Balvín" # optional fediverse_creator = "@petrbalvin@mastodon.social" # optional, for Mastodon attribution -tags = ["ruby", "web"] # optional +tags = ["python", "web"] # optional draft = false # optional excerpt = "Short summary" # optional, auto-derived from body if missing all_langs = false # optional, show this post in every language @@ -100,7 +138,7 @@ cs = "muj-prispevek" # Heading -Body in **Markdown**, rendered by kramdown. +Body in **Markdown**, rendered by python-markdown. ``` Posts are organised on disk as either: @@ -156,20 +194,28 @@ per-post value takes precedence. becoming `false`). - **No whitespace pitfalls** — indentation is not significant. +The engine parses it with the Python 3.11+ standard library `tomllib`; no +extra TOML dependency is needed at runtime. + --- ## Configuration -A single TOML file, auto-generated at `/etc/volumen/config.toml` on first -start. See [`docs/configuration.md`](docs/configuration.md) for the full +A single TOML file, auto-generated at `/etc/volumen/config.toml` when you +run `volumen init` (or, for local development, by `volumen serve` on first +start). See [`docs/configuration.md`](docs/configuration.md) for the full schema. ```toml [server] -host = "0.0.0.0" +host = "::" # IPv6 + IPv4 fallback; use "::1" behind nginx port = 9090 +env = "production" +trust_proxy = true +cookie_secure = true # required in production behind HTTPS content_dir = "/var/lib/volumen/posts" +users_file = "/var/lib/volumen/users.toml" [site] title = "My Blog" @@ -179,17 +225,26 @@ language = "en" author = "Anonymous" [admin] -password_hash = "" -session_key = "" +password_hash = "" # populated by `volumen init` +session_key = "" # populated by `volumen init` session_ttl = 86400 +min_password_length = 10 +cookie_secure = false # set to true in production behind HTTPS +``` + +`volumen init` writes the bootstrap admin hash and a fresh session key for +you. To rotate the admin password later: + +```bash +sudo volumen hash-password # returns a scrypt$… string; paste into [admin].password_hash ``` --- ## Public API -The API is at `/api/volumen/`. CORS is open (`*`) for read endpoints; admin routes -are same-origin only. +The API is at `/api/volumen/`. CORS is open (`*`) for read endpoints; admin +routes are same-origin only. ### `GET /api/volumen/posts` @@ -197,6 +252,7 @@ are same-origin only. |-------|--------|---------|----------------------| | lang | string | — | Filter by language | | tag | string | — | Filter by tag | +| q | string | — | Search in title/body | | page | int | 1 | Page number | | limit | int | 20 | Posts per page | @@ -205,6 +261,8 @@ are same-origin only. "page": 1, "page_size": 20, "total": 42, + "has_next": true, + "has_prev": false, "posts": [ { "slug": "welcome", @@ -212,7 +270,7 @@ are same-origin only. "excerpt": "A short introduction…", "date": "2026-01-15", "lang": "en", - "tags": ["intro", "ruby"], + "tags": ["intro", "python"], "author": "Petr Balvín", "translations": { "cs": "vitame-vas" }, "url": "/api/volumen/posts/welcome" @@ -231,7 +289,7 @@ Returns the full post including the raw Markdown body and the rendered HTML. "title": "Welcome to volumen", "date": "2026-01-15", "lang": "en", - "tags": ["intro", "ruby"], + "tags": ["intro", "python"], "excerpt": "…", "body": "# Welcome to **volumen**\n\n…", "html": "

Welcome to volumen

…", @@ -245,13 +303,22 @@ Returns the full post including the raw Markdown body and the rendered HTML. ```bash just # list recipes -just install # bundle install -just run # run the dev server (exe/volumen serve) -just build # rubocop (zero warnings) + build the gem → pkg/volumen.gem -just test # bundle exec rake test (minitest) -just uninstall # remove build artifacts (pkg/, *.gem) +just install # uv sync (creates .venv/ from uv.lock) +just run # run the dev server (volumen serve) +just dev # run against ./posts and ./config.toml on :9090 +just build # ruff check + ruff format --check (zero warnings) +just test # uv run pytest +just fmt # ruff format + ruff check --fix +just uninstall # remove build artifacts (dist/, __pycache__, .pytest_cache, .ruff_cache) ``` +The console script lives at `src/volumen/cli.py` and is registered as +`volumen` via the `[project.scripts]` entry in `pyproject.toml`; `uv run` +shims it onto `PATH` inside `.venv/`. In production the CLI is installed +globally via `uv tool install volumen` (or `pipx install volumen`) and the +operational bootstrap is done with `sudo volumen init` — +see [`docs/deployment.md`](docs/deployment.md). + --- ## Public site integration @@ -280,4 +347,4 @@ commit conventions, the pull request flow, and how releases are cut. ## License MIT — see [LICENSE](LICENSE). -Copyright © 2026 [Petr Balvín](https://petrbalvin.org) +Copyright © 2026 [Petr Balvín](https://petrbalvin.org) \ No newline at end of file diff --git a/config.toml.example b/config.toml.example index 31b60e3..120baf6 100644 --- a/config.toml.example +++ b/config.toml.example @@ -2,8 +2,17 @@ # Copy to config.toml and adjust. Keep real admin hashes out of Git. [server] -host = "127.0.0.1" +host = "::" port = 9090 +# Environment label: "development" or "production". Affects startup +# safety checks (session key length, secure cookies, password policy). +env = "development" +# Set true ONLY when running behind a trusted reverse proxy that +# terminates TLS. The proxy must strip client-supplied +# X-Forwarded-Proto headers. +trust_proxy = false +# Set true in production to force `Secure` flag on session cookies. +cookie_secure = false content_dir = "./posts" users_file = "./users.toml" @@ -16,7 +25,13 @@ language = "cs" author = "Petr Balvín" [admin] -# Generate a hash with: bundle exec exe/volumen hash-password +# Generate a hash with: uv run volumen hash-password password_hash = "" session_key = "" session_ttl = 86400 +# Minimum new-password length (default 10). +min_password_length = 10 +# Maximum new-password length (cap to avoid scrypt abuse). +max_password_length = 1024 +# Maximum upload size in bytes (default 10 MB). +max_upload_bytes = 10485760 \ No newline at end of file diff --git a/docs/api-reference.md b/docs/api-reference.md index 2e350aa..18a37ae 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -33,6 +33,7 @@ Posts with `all_langs = true` in their frontmatter match every `lang` filter. |-------|--------|---------|--------------------| | lang | string | — | Filter by language | | tag | string | — | Filter by tag | +| q | string | — | Case-insensitive search across title and body | | page | int | 1 | Page number | | limit | int | 20 | Per page (1–100) | @@ -41,6 +42,8 @@ Posts with `all_langs = true` in their frontmatter match every `lang` filter. "page": 1, "page_size": 20, "total": 1, + "has_next": false, + "has_prev": false, "posts": [ { "slug": "hello", @@ -64,7 +67,7 @@ Posts with `all_langs = true` in their frontmatter match every `lang` filter. Single post including the raw Markdown body and rendered HTML. Optional `?lang=` selects a translation. Returns `404` with `{"error":"not_found"}` if -no match. +no match. Drafts return `404` with `{"error":"draft"}`. ```json { @@ -93,7 +96,14 @@ Tag cloud with counts (published posts only), most frequent first. { "tags": [{ "name": "intro", "count": 1 }] } ``` -## `GET /api/volumen/feed.xml`, `GET /api/volumen/sitemap.xml` +## `GET /api/volumen/feed.xml`, `GET /api/volumen/feed.json`, `GET /api/volumen/sitemap.xml` -RSS 2.0 feed (latest 20 posts) and a sitemap of post URLs, built from -`site.base_url`. +- `feed.xml` — RSS 2.0 feed of the 20 most recent posts. +- `feed.json` — [JSON Feed 1.1](https://www.jsonfeed.org/) document with + `content_html` and `date_published` per item. +- `sitemap.xml` — XML sitemap built from `site.base_url`. + +## `OPTIONS /api/volumen/*` + +Preflight requests return an empty `204` with the same CORS headers as the GET +endpoints, so browsers can reach the JSON API cross-origin. \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index 75c3849..094642d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,7 +3,7 @@ > Status: the core library, the public JSON API, and the server-rendered admin > (login, CSRF, post CRUD, Markdown editor + live preview) are implemented. -**volumen** is a small, file-based Markdown blog engine written in CRuby. It +**volumen** is a small, file-based Markdown blog engine written in Python. It has no database: every post is a `.md` file on disk with a TOML frontmatter block. The engine reads those files, exposes them as a JSON API, and ships a server-rendered admin for editing them. @@ -12,35 +12,35 @@ server-rendered admin for editing them. - **File-based** — posts are plain `.md` files, safe to commit to Git and edit by hand or through the admin. -- **Single-admin** — one password, one content directory, one user. -- **Self-contained** — runs as a single Ruby process; no Node build step is - required to operate the engine or its admin. -- **Dependency-light** — a small, well-chosen set of gems (Sinatra, kramdown, - toml-rb, puma) rather than a full framework. +- **Self-contained** — runs as a single Python process (Uvicorn) under FastAPI; + no Node build step is required to operate the engine or its admin. +- **Dependency-light** — a small, well-chosen set of PyPI packages (FastAPI, + python-markdown, Jinja2, itsdangerous, python-multipart) rather than a + full-stack framework. The standard library covers cryptography. - **Markdown-first** — the visual editor round-trips through the same renderer that powers the public API, so stored content is always Markdown. ## Components ``` -lib/volumen/ - version.rb # gem version - config.rb # loads /etc/volumen/config.toml, applies CLI overrides - frontmatter.rb # parse/serialise the `+++ … +++` TOML block - post.rb # Post model: metadata + raw body + rendered HTML - store.rb # reads the content directory into Post objects (mtime-cached) - markdown.rb # kramdown wrapper (render +
/
for titled images) - feed_helpers.rb # RSS / JSON Feed / sitemap generation - users.rb # users.toml + admin/author roles - password.rb # scrypt hashing/verification (OpenSSL::KDF) - api_routes.rb # /api/volumen/* (Sinatra routes) - admin_routes.rb # /admin/* (Sinatra routes) - server.rb # Sinatra app: composes the two route modules, shared helpers - cli.rb # command dispatcher +src/volumen/ + __init__.py # VERSION + in-memory login-attempt rate limiter + cli.py # argparse entry point: serve, hash-password, version + config.py # loads config.toml via stdlib tomllib, deep-merges defaults + frontmatter.py # parse/serialise the `+++ … +++` TOML block + post.py # Post model: metadata + raw body + rendered HTML + store.py # reads the content directory into Post objects (mtime-cached) + markdown.py # python-markdown + pymdown-extensions wrapper,
/
+ feed_helpers.py # RSS / JSON Feed / sitemap generation + users.py # users.toml + admin/author roles + password.py # scrypt hashing/verification (hashlib + secrets.compare_digest) + api_routes.py # /api/volumen/* (FastAPI APIRouter) + admin_routes.py # /admin/* + /media/* (FastAPI APIRouter) + server.py # create_app factory, middleware, dependency helpers web/ - views/ # ERB templates for the admin - public/ # static assets (volumen-logo.svg, volumen-icon.svg) -exe/volumen # CLI: serve, hash-password, version, help + templates/ # Jinja2 templates for the admin + static/ # static assets (volumen-logo.svg, volumen-icon.svg) +exe/volumen # thin shim that calls volumen.cli:main ``` ## Request flow @@ -48,22 +48,38 @@ exe/volumen # CLI: serve, hash-password, version, help ```mermaid graph TD A[HTTP request] --> B{Route} - B -->|/api/volumen/*| C[api_routes.rb] - B -->|/admin/*| D[admin_routes.rb + CSRF + CSP] - C --> E[Store] - D --> E + B -->|/api/volumen/*| C[api_routes.py] + B -->|/admin/*| D[admin_routes.py + CSRF + CSP] + B -->|/media/*| E[admin_routes.py media_router] + C --> F[Store] + D --> F D --> U[Users] - C --> F[FeedHelpers] + C --> H[FeedHelpers] C --> G[Markdown renderer] - E --> H[(content_dir/*.md)] - E --> I[(content_dir/media/*)] - U --> J[(users.toml)] - F --> E - G --> K[
for titled images] + F --> I[(content_dir/*.md)] + F --> J[(content_dir/media/*)] + U --> K[(users.toml)] + H --> F + G --> L[
for titled images] + D --> M[SessionMiddleware + CSPMiddleware] + C --> M + E --> M ``` +## Middleware stack + +- `SessionMiddleware` (Starlette) — signed cookie session, signed with + `itsdangerous` using the secret derived from `[admin].session_key`. Set to + `HttpOnly` and `SameSite=Strict`; `Secure` is added by `SecureCookieMiddleware` + when the request was forwarded over HTTPS. +- `SecureCookieMiddleware` (custom) — inspects `X-Forwarded-Proto` (when + `[server].trust_proxy = true`) and exposes `request.state.session_secure` + for downstream code that needs to know whether the request is HTTPS. +- `CSPMiddleware` (custom) — injects the `Content-Security-Policy` header on + every `/admin/*` response. + ## Public site integration The public website (e.g. `petrbalvin.org`, a Vue 3 + Vite + Tailwind SPA) consumes volumen's JSON API. volumen does not render the public site itself; it -is headless for the front-end and only renders its own admin. +is headless for the front-end and only renders its own admin. \ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md index 697ddab..8179679 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,20 +1,28 @@ # Configuration -> Status: initial draft. Field semantics are finalised as the loader lands. +> Status: the loader in `src/volumen/config.py` is the source of truth. This +> document lists every key it understands, with type, default, and meaning. volumen is configured by a single **TOML** file. By default it lives at `/etc/volumen/config.toml` and is auto-generated on first start. CLI flags passed to `volumen serve` override the file. +A template you can copy as a starting point is at +[`config.toml.example`](../config.toml.example) in the repository root. The +template ships with development defaults (`host = "::"`, `env = "development"`, +`cookie_secure = false`) — flip them for production. + ## Example `config.toml` ```toml [server] -host = "0.0.0.0" +host = "::1" port = 9090 +env = "production" +trust_proxy = true -# Directory containing your Markdown posts. content_dir = "/var/lib/volumen/posts" +users_file = "/var/lib/volumen/users.toml" [site] title = "My Blog" @@ -22,22 +30,60 @@ description = "A blog powered by volumen." base_url = "https://example.com" language = "en" author = "Anonymous" -# Site-wide fediverse handle (e.g. Mastodon). Exposed as `fediverse_creator` -# on /api/volumen/site and used as the fallback in the RSS / JSON feeds when -# a post does not set its own `fediverse_creator` frontmatter field. Leave -# empty if you do not publish to the fediverse. fediverse_creator = "@you@example.social" [admin] -# scrypt hash produced by `volumen hash-password`. password_hash = "" -# Secret used to sign session cookies. Leave empty to auto-generate per start -# (sessions reset on restart). session_key = "" -# Session lifetime in seconds (default 24h). session_ttl = 86400 +min_password_length = 10 +max_password_length = 1024 +max_upload_bytes = 10485760 # 10 MB +cookie_secure = true ``` +## Key reference + +### `[server]` + +| Key | Type | Default | Description | +|----------------|---------|---------------|-------------| +| `host` | string | `"::"` | Bind address. `"::"` listens on IPv6 with automatic IPv4 fallback; use `"::1"` (or `"127.0.0.1"`) when running behind a reverse proxy. | +| `port` | integer | `9090` | TCP port the Uvicorn worker binds to. | +| `env` | string | `"development"` | Runtime environment. `"production"` enables strict startup safety checks (refuses to boot with an empty `admin.password_hash`, warns on a short `admin.session_key`, enforces `cookie_secure`); `"development"` is more forgiving for local runs. | +| `trust_proxy` | boolean | `false` | When `true`, honour the `X-Forwarded-Proto` header set by an upstream reverse proxy (nginx). Set `true` ONLY when running behind a trusted proxy that strips client-supplied values; otherwise an attacker could fake `https://` and force `Secure` cookies. | +| `cookie_secure`| boolean | `false` | Mark session cookies as `Secure` (browser only sends them over HTTPS). Default `false` so local HTTP development works without TLS; set to `true` in production behind HTTPS. The runtime also auto-promotes cookies to `Secure` when `[server].trust_proxy = true` and `X-Forwarded-Proto: https` is observed. | + +### Top-level + +| Key | Type | Default | Description | +|----------------|---------|----------------------------------|-------------| +| `content_dir` | string | `"/var/lib/volumen/posts"` | Directory of `.md` files with `+++` TOML frontmatter. | +| `users_file` | string | `"/var/lib/volumen/users.toml"` | File storing admin users (managed from the admin Settings page). | + +### `[site]` + +| Key | Type | Default | Description | +|---------------------|-----------------|--------------------------|-------------| +| `title` | string | `"My Blog"` | Exposed as `site.title`. | +| `description` | string | `"A blog powered by volumen."` | Exposed as `site.description`. | +| `base_url` | string | `"https://example.com"` | Used to build absolute URLs in the RSS feed, JSON feed, sitemap, and the public API. | +| `language` | string | `"en"` | Default language for posts and the admin UI. | +| `author` | string | `"Anonymous"` | Default author when a post frontmatter omits it. | +| `fediverse_creator` | string \| null | `null` | Site-wide fediverse handle (e.g. `@user@instance.tld`). Exposed as `site.fediverse_creator` and rendered as `` in the RSS feed and `authors[].name` in the JSON feed. Per-post frontmatter overrides it. | + +### `[admin]` + +| Key | Type | Default | Description | +|-----------------------|---------|-----------|-------------| +| `password_hash` | string | `""` | Bootstrap admin login: paste a hash from `volumen hash-password` to sign in as user `admin`. Once you create users from the Settings page, `users_file` wins. | +| `session_key` | string | `""` | Secret used to sign session cookies. Must be `>= 64` bytes; empty value = a random secret per start (sessions reset on restart, do not use in production). | +| `session_ttl` | integer | `86400` | Session lifetime in seconds (default 24h). `0` or negative = no expiry until the browser session ends. | +| `min_password_length` | integer | `10` | Minimum length required for admin-chosen passwords on the Settings page. Must be `>= 8`; the bootstrap hash is not affected. | +| `max_password_length` | integer | `1024` | Maximum length for admin-chosen passwords (cap to avoid scrypt abuse; scrypt is CPU-bound by design). | +| `max_upload_bytes` | integer | `10485760` | Maximum upload size in bytes (default 10 MB). Uploads larger than this are rejected with HTTP 413. | +| `cookie_secure` | boolean | `false` | Mark session cookies as `Secure` (browser only sends them over HTTPS). Default `false` so local HTTP development works without TLS; set to `true` in production behind HTTPS. The runtime also auto-promotes cookies to `Secure` when `[server].trust_proxy = true` and `X-Forwarded-Proto: https` is observed. | + ## CLI overrides ```bash @@ -55,3 +101,7 @@ volumen serve --config /etc/volumen/config.toml \ The same format is used for post frontmatter; see the post format section in the project [`README.md`](../README.md). + +The Python standard library `tomllib` (3.11+) parses the file at load time; no +extra TOML dependency is required at runtime. `tomli-w` is bundled for the +admin to round-trip TOML frontmatter on post edits. \ No newline at end of file diff --git a/docs/deployment.md b/docs/deployment.md index 087b14e..a48e897 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -2,95 +2,113 @@ > Status: production deployment on Linux with systemd + nginx. -volumen runs as a single Ruby process (Puma) bound to localhost, behind nginx -which terminates TLS and reverse-proxies the API and admin. The public website -(e.g. a Vue SPA) is served separately and consumes the JSON API. +volumen runs as a single Python process (Uvicorn via `fastapi[standard]`) bound +to localhost, behind nginx which terminates TLS and reverse-proxies the API and +admin. The public website (e.g. a Vue SPA) is served separately and consumes +the JSON API. + +The CLI binary is installed system-wide via `uv tool install` (or `pipx +install`); the *operational* bootstrap — config, data dirs, admin password, +optional systemd unit — is performed by the `volumen init` Python subcommand +that replaces the old `install.sh` bash script. ## Requirements -- **Ruby ≥ 3.3** with a C toolchain (Puma compiles a native extension). - - Fedora: `sudo dnf install ruby ruby-devel @development-tools openssl-devel` - - Debian/Ubuntu: `sudo apt install ruby-full build-essential libssl-dev` -- **nginx** and a TLS certificate (e.g. via certbot). -- A dedicated system user, e.g. `volumen`. +- **Python ≥ 3.14** (the bundled wheel ships its own interpreter when you use + `uv tool install` / `pipx install`; the system Python is only required if + you're building from source). + - Fedora: `sudo dnf install python3.14` + - Debian/Ubuntu: `sudo apt install python3.14 python3-venv python3-pip` +- **[`uv`](https://docs.astral.sh/uv/)** (recommended) — install with + `curl -LsSf https://astral.sh/uv/install.sh | sh`. Alternatively, + [`pipx`](https://pipx.pypa.io/) ships in most distro repos. +- **nginx** and a TLS certificate (e.g. via certbot) for the public site. +- A dedicated system user, `volumen`, created automatically by + `volumen init` when run as root. -## Layout +## Install the CLI + +```bash +# Pick one: +uv tool install volumen +# or +pipx install volumen + +# Both put a self-contained `volumen` binary on PATH. Verify: +volumen version +``` + +For platforms where the wheel is not available (or for development), build it +from a source checkout: + +```bash +git clone https://sourcedock.dev/petrbalvin/volumen.git +cd volumen +uv tool install . +``` + +## Bootstrap the deployment + +### System install (root, systemd) + +```bash +sudo volumen init +sudo systemctl status volumen +``` + +`volumen init` walks through: + +1. Whether `config_path` already exists — if so, leaves it untouched unless + `--force` is passed (and even then it makes a timestamped `.bak..toml` + first). +2. Creates `/etc/volumen/`, `/var/lib/volumen/posts` (and a `media/` + subdirectory), `/var/lib/volumen/users.toml`. +3. Creates the system user `volumen` if missing (`useradd --system …`). +4. Prompts for an admin password (`getpass`), hashes it via scrypt, writes the + `password_hash` into the rendered config. +5. Generates a fresh 64-byte session key (`secrets.token_hex(64)`) and writes + it into `[admin].session_key`. +6. Forces production-style settings: `host = "::1"`, `env = "production"`, + `trust_proxy = true`, `cookie_secure = true`. +7. With `--systemd` (default under root), writes + `/etc/systemd/system/volumen.service` and runs + `systemctl daemon-reload && systemctl enable --now volumen`. + +Pass `--no-enable` to install the unit without starting it; pass `--force` to +back up and overwrite an existing config. The full flag list lives in +`volumen init --help`. + +### Per-user install (no root) + +```bash +volumen init --local +``` + +Per-user paths: `~/.config/volumen/config.toml`, +`~/.local/share/volumen/posts`, `~/.local/share/volumen/users.toml`. No +system user is created and no systemd unit is installed. `--systemd` is +rejected in this mode. + +## Configuration locations | Path | Purpose | |------|---------| -| `/opt/volumen` | source checkout (or installed gem) | -| `/etc/volumen/config.toml` | configuration (auto-generated on first run) | +| `/etc/volumen/config.toml` | configuration (rendered by `volumen init` on first run) | | `/var/lib/volumen/posts` | Markdown posts | -| `/var/lib/volumen/users.toml` | admin users (created from Settings) | | `/var/lib/volumen/posts/media` | uploaded images | +| `/var/lib/volumen/users.toml` | admin users (created from Settings) | +| `/etc/systemd/system/volumen.service` | systemd unit (only when `--systemd`) | +| `~/.config/volumen/config.toml` | per-user config (`--local`) | +| `~/.local/share/volumen/posts` | per-user data dir (`--local`) | +| `~/.local/share/volumen/users.toml` | per-user users file (`--local`) | -```bash -sudo useradd --system --home /var/lib/volumen --shell /usr/sbin/nologin volumen -sudo mkdir -p /etc/volumen /var/lib/volumen/posts -sudo chown -R volumen:volumen /var/lib/volumen -``` - -## Install - -### Quick install (Linux + systemd) - -From a cloned repository, run the installer as root. It installs the Ruby -toolchain from the distro repositories on **Fedora** and **openEuler** (prints -guidance on other distros), creates the `volumen` user and directories, runs -`bundle install`, generates `config.toml`, and installs and enables the systemd -unit: - -```bash -sudo git clone https://codeberg.org/petrbalvin/volumen.git /opt/volumen -cd /opt/volumen -sudo ./install.sh -``` - -The service is enabled but not started — finish by setting the admin password -(printed steps), then `sudo systemctl start volumen`. The manual steps below -describe what the script does. - -### Manual install - -From a source checkout (reproducible, no publishing needed): - -```bash -sudo git clone https://codeberg.org/petrbalvin/volumen.git /opt/volumen -cd /opt/volumen -sudo bundle install --deployment -``` - -Alternatively build and install the gem (`just build` produces `pkg/volumen.gem`, -then `gem install pkg/volumen.gem` puts `volumen` on the PATH). - -## First run and the admin password - -The first `serve` writes a commented `config.toml` if it is missing: - -```bash -sudo -u volumen bundle exec exe/volumen serve \ - --config /etc/volumen/config.toml \ - --content /var/lib/volumen/posts -# → "volumen: wrote a default config to /etc/volumen/config.toml; review it and restart." -``` - -Generate a password hash and a stable session secret, then edit the config: - -```bash -bundle exec exe/volumen hash-password # → scrypt$... -openssl rand -hex 64 # → session_key -sudo -u volumen $EDITOR /etc/volumen/config.toml -``` - -Set `host = "127.0.0.1"`, your `site.base_url`, the `admin.password_hash`, and a -`admin.session_key` (so sessions survive restarts). You can now sign in as user -`admin`; from the **Settings** page you can add more users (roles `admin` / -`author`) and change passwords. From then on `users.toml` is the source of -truth. +The config and users files are owned by `volumen:volumen` and `chmod 600` after +a system install — they may carry password hashes, so never make them +world-readable. ## systemd -`/etc/systemd/system/volumen.service`: +`/etc/systemd/system/volumen.service` (rendered by `volumen init --systemd`): ```ini [Unit] @@ -98,10 +116,12 @@ Description=volumen blog engine After=network.target [Service] +Type=simple User=volumen Group=volumen -WorkingDirectory=/opt/volumen -ExecStart=/usr/bin/bundle exec exe/volumen serve --config /etc/volumen/config.toml --content /var/lib/volumen/posts +WorkingDirectory=/etc/volumen +Environment=PYTHONUNBUFFERED=1 +ExecStart=/usr/local/bin/volumen serve --config /etc/volumen/config.toml --content /var/lib/volumen/posts Restart=on-failure RestartSec=2 NoNewPrivileges=true @@ -114,26 +134,34 @@ PrivateTmp=true WantedBy=multi-user.target ``` +Hardening notes — `NoNewPrivileges`, `ProtectSystem=strict` (the entire +filesystem is read-only except for `ReadWritePaths`), `ProtectHome` +(`/home`, `/root`, `/run/user` are inaccessible), `PrivateTmp` (private +`/tmp`), and a two-second restart delay on failure. + +Inspect health with: + ```bash -sudo systemctl daemon-reload -sudo systemctl enable --now volumen sudo systemctl status volumen journalctl -u volumen -f +volumen status # config / data dir / unit / service state +volumen status --json # machine-readable ``` ## FreeBSD (rc.d) -volumen runs on FreeBSD too — it is plain CRuby plus gems; only Puma (and its -`nio4r` dependency) build a native extension, which compiles with the base -clang toolchain. +volumen runs on FreeBSD too — it is plain CPython with no compiled extensions. ```sh -pkg install ruby -# then install volumen as above (git clone + bundle install) +pkg install python3 +# install uv (https://docs.astral.sh/uv/getting-started/installation/) +uv tool install volumen +sudo volumen init --local # no systemd on FreeBSD ``` Conventional FreeBSD paths: config in `/usr/local/etc/volumen/`, data in -`/var/db/volumen/`. Service script `/usr/local/etc/rc.d/volumen`: +`/var/db/volumen/`. Drop a tiny `rc.d` script in `/usr/local/etc/rc.d/volumen` +that wraps the `volumen` console script: ```sh #!/bin/sh @@ -150,14 +178,13 @@ load_rc_config $name : ${volumen_enable:="NO"} : ${volumen_user:="volumen"} -: ${volumen_dir:="/usr/local/volumen"} : ${volumen_config:="/usr/local/etc/volumen/config.toml"} : ${volumen_content:="/var/db/volumen/posts"} pidfile="/var/run/${name}.pid" command="/usr/sbin/daemon" command_args="-f -r -P ${pidfile} -u ${volumen_user} \ - /bin/sh -c 'cd ${volumen_dir} && exec bundle exec exe/volumen serve --config ${volumen_config} --content ${volumen_content}'" + /bin/sh -c 'exec volumen serve --config ${volumen_config} --content ${volumen_content}'" run_rc_command "$1" ``` @@ -165,6 +192,7 @@ run_rc_command "$1" Enable and start: ```sh +chmod +x /usr/local/etc/rc.d/volumen sysrc volumen_enable=YES service volumen start ``` @@ -266,22 +294,34 @@ right block. Apply with `sudo nginx -t && sudo systemctl reload nginx`. ## Updating ```bash -cd /opt/volumen -sudo git pull -sudo bundle install --deployment +uv tool upgrade volumen sudo systemctl restart volumen +volumen status # confirm: config_exists, password_hash_set, session_key_ok, … ``` +The CLI binary is replaced in place by `uv tool upgrade`; the systemd unit's +`ExecStart` already points at the on-PATH `volumen` console script, so a +restart is all that's needed. The persistent state (`config.toml`, +`users.toml`, posts) is untouched. + +If you used the legacy `install.sh` workflow (no longer documented, the script +was removed), you can migrate forward by deleting `/etc/systemd/system/volumen.service` ++ `/opt/volumen` and then running `uv tool install volumen && sudo volumen init`. + ## Security notes -- Bind to `127.0.0.1`; only nginx is public. TLS is terminated by nginx. +- Bind to `::1` (or `127.0.0.1`); only nginx is public. TLS is terminated by + nginx. - Session cookies are `HttpOnly` and `SameSite=Strict`, and gain the `Secure` attribute automatically on HTTPS requests (detected via `X-Forwarded-Proto`, - which the nginx snippet above sets) — so the cookie never travels over plain - HTTP in production, while local HTTP testing still works. Combined with - per-form CSRF tokens this protects the admin. Set a stable `admin.session_key` - so sessions survive restarts. + which the nginx snippet above sets, and enabled by + `[server].cookie_secure = true`, set automatically by `volumen init + --systemd`) — so the cookie never travels over plain HTTP in production, + while local HTTP testing still works. Combined with per-form CSRF tokens + this protects the admin. A stable `[admin].session_key` (also written by + `volumen init`) lets sessions survive restarts. - Keep `config.toml` and `users.toml` readable only by the `volumen` user - (`chmod 600`). Both may contain password hashes. -- `volumen hash-password` reads the password without echo; never pass passwords - on the command line. + (`chmod 600`). Both may contain password hashes. The installer enforces + this automatically. +- Use `volumen hash-password` to rotate; it reads the password without echo. + Never pass passwords on the command line. diff --git a/docs/security.md b/docs/security.md index f57509b..2c1ce04 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2,7 +2,7 @@ > Status: threat model and security posture for the engine as implemented. -volumen is a small, file-based blog engine: a single Ruby process exposing a +volumen is a small, file-based blog engine: a single Python process exposing a read-only public JSON API and a session-authenticated admin, behind nginx. This document describes the trust model, the controls in place, and the known limitations. @@ -13,33 +13,37 @@ than opening a public issue. ## Trust model - **Content authors are trusted.** Posts are written by authenticated admin - users. kramdown renders Markdown and **passes raw HTML through** on purpose, - so an author can embed HTML. The stored/served `html` is therefore only as - safe as the people who can log in. If you need to accept posts from untrusted - authors, add an HTML sanitiser before serving. + users. python-markdown renders Markdown with a few pymdown-extensions + extensions. The engine does **not** strip raw HTML from post bodies by + default (authors are the trusted admin); if you need to accept posts from + untrusted authors, add a sanitiser (e.g. `bleach`) before serving. - **The public API is read-only.** It exposes published (non-draft) posts, tags, a feed and a sitemap. No public endpoint mutates state. ## Authentication -- Passwords are hashed with **scrypt** via `OpenSSL::KDF` (`N=16384`, `r=8`, +- Passwords are hashed with **scrypt** via `hashlib.scrypt` (`n=16384`, `r=8`, `p=1`, 32-byte key, 16-byte random salt). Verification uses - `OpenSSL.fixed_length_secure_compare` (constant-time). + `secrets.compare_digest` (constant-time) on the derived bytes. - Login takes a **username + password**. The error message is intentionally generic ("Invalid username or password.") to avoid user enumeration. -- Sessions use signed cookies (`Rack::Session::Cookie`) marked **`HttpOnly`** - and **`SameSite=Strict`**. The **`Secure`** attribute is added automatically on - HTTPS requests (detected via `request.ssl?` / `X-Forwarded-Proto`), so the - cookie never travels over plain HTTP in production. The signing secret comes - from `admin.session_key`; if it is shorter than 64 bytes a random secret is - generated per start (sessions then reset on restart, so set a stable key in - production). +- Sessions use **signed cookies** via Starlette's `SessionMiddleware` + (`itsdangerous` TimestampSigner under the hood), marked **`HttpOnly`** and + **`SameSite=Strict`**. The **`Secure`** attribute is added by + `SecureCookieMiddleware` when `X-Forwarded-Proto: https` is observed (only + honoured when `[server].trust_proxy = true`) so the cookie never travels + over plain HTTP in production while local HTTP testing still works. The + signing secret comes from `admin.session_key`; if it is shorter than 64 + bytes a random secret is generated per start (sessions then reset on + restart, so set a stable key in production). +- A minimum password length of `[admin].min_password_length` (default `10`, + must be `>= 8`) is enforced on the Settings page password-change form. ## Authorization (roles) - Two roles: **admin** (full access, manages users) and **author** (posts and own account only). -- User management (`add`/`delete`/role change) is gated by `require_admin!` +- User management (`add`/`delete`/role change) is gated by `require_admin` **on the server**, not merely hidden in the UI. - Safeguards prevent lockout: the **last admin** cannot be deleted or demoted, and a user cannot delete their own account or change their own role. @@ -47,13 +51,14 @@ than opening a public issue. ## CSRF - Every state-changing form (`POST`) carries a per-session token - (`SecureRandom.hex(32)`), validated with `Rack::Utils.secure_compare`. + (`secrets.token_hex(32)`), validated with `secrets.compare_digest`. Requests without a valid token get `403`. - `SameSite=Strict` cookies provide a second, independent layer. ## Content Security Policy (admin) -All `/admin/*` responses send a strict **Content-Security-Policy** header: +All `/admin/*` responses send a strict **Content-Security-Policy** header +(via `CSPMiddleware`): ``` default-src 'self'; @@ -66,8 +71,8 @@ form-action 'self'; frame-ancestors 'none' ``` -The inline allowances are required by the server-rendered admin (ERB templates -emit small inline `world") + assert " None: + rendered = render('') + assert "onerror" not in rendered.lower() + assert "alert" not in rendered.lower() + + def test_javascript_url_sanitised(self) -> None: + rendered = render("[click me](javascript:alert(1))") + assert "javascript:" not in rendered.lower() + + def test_data_image_url_kept(self) -> None: + rendered = render("![alt](data:image/webp;base64,UklGRg==)") + assert " None: + rendered = render('![alt](url "caption")') + assert "
" in rendered + assert "caption" in rendered + + +# --------------------------------------------------------------------------- +# Password hashing policy +# --------------------------------------------------------------------------- + + +class TestPasswordPolicy: + def test_strong_hash_passes(self) -> None: + encoded = hash_password("a-strong-password") + assert verify_password("a-strong-password", encoded) is True + + def test_weak_params_rejected(self) -> None: + encoded = f"scrypt$8$1$1${'A' * 24}${'B' * 32}" + assert verify_password("anything", encoded) is False + + def test_needs_rehash_for_strong_hash(self) -> None: + encoded = hash_password("a-strong-password") + assert needs_rehash(encoded) is False + + def test_needs_rehash_for_weak_hash(self) -> None: + encoded = f"scrypt$8$1$1${'A' * 24}${'B' * 32}" + assert needs_rehash(encoded) is True + + def test_needs_rehash_for_malformed(self) -> None: + assert needs_rehash("not-a-hash") is True + + def test_module_constants_match_policy(self) -> None: + # Policy floor equals current implementation. + assert COST_N >= 16384 + assert BLOCK_R >= 8 + assert PARALLEL_P >= 1 + assert KEY_LENGTH >= 32 + + +# --------------------------------------------------------------------------- +# HTTP security headers and CSP +# --------------------------------------------------------------------------- + + +class TestSecurityHeaders: + def test_global_security_headers_present(self, client: TestClient) -> None: + response = client.get("/api/volumen/site") + assert response.headers.get("x-content-type-options") == "nosniff" + assert response.headers.get("x-frame-options") == "DENY" + assert response.headers.get("referrer-policy") == "strict-origin-when-cross-origin" + assert "permissions-policy" in response.headers + + def test_csp_on_public_routes(self, client: TestClient) -> None: + response = client.get("/api/volumen/site") + csp = response.headers.get("content-security-policy", "") + assert "default-src 'self'" in csp + # Public routes have script-src but without nonce (nonce is admin-only) + assert "script-src 'self'" in csp + assert "nonce-" not in csp + + def test_admin_route_has_csp_with_nonce(self, admin_client: TestClient) -> None: + response = admin_client.get("/admin/login") + csp = response.headers.get("content-security-policy", "") + assert "default-src 'self'" in csp + assert "'unsafe-inline'" not in csp + assert "nonce-" in csp + + def test_https_only_cookie_unset_when_untrusted(self, client: TestClient) -> None: + # Default trust_proxy=false — X-Forwarded-Proto is ignored. + response = client.get("/admin/login") + # No cookie issued, but the middleware must mark session_secure=False. + assert response.status_code == 200 + + def test_https_only_cookie_with_trusted_proxy(self, admin_config_dir) -> None: + # Build a fresh app with trust_proxy=True and confirm it boots. + from volumen.app import create_app + from volumen.config import Config + from volumen.store import Store + + config = Config.load(path=str(admin_config_dir / "config.toml")) + config._data["server"]["trust_proxy"] = True # type: ignore[index] + store = Store(config.content_dir) + app = create_app(config, store) + client = TestClient(app) + resp = client.get("/admin/login") + assert resp.status_code == 200 + + def test_cookie_secure_false_without_proxy_or_flag(self, admin_client: TestClient) -> None: + # When trust_proxy=false and cookie_secure=false, the SessionMiddleware + # is configured with https_only=False. + resp = admin_client.get("/admin/login") + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# Config validation +# --------------------------------------------------------------------------- + + +class TestConfigValidation: + def test_default_config_is_valid(self, config) -> None: + # Should not raise. + config.validate() + + def test_invalid_port_rejected(self, tmp_path) -> None: + from volumen.config import Config + + (tmp_path / "posts").mkdir() + cfg = Config.load( + path="/nonexistent.toml", + overrides={ + "users_file": str(tmp_path / "users.toml"), + "content": str(tmp_path / "posts"), + }, + ) + cfg._data["server"]["port"] = 999999 # type: ignore[index] + try: + cfg.validate() + except Exception as exc: + assert "port" in str(exc).lower() + else: + raise AssertionError("expected ConfigError") + + def test_invalid_env_rejected(self, tmp_path) -> None: + from volumen.config import Config, ConfigError + + cfg = Config.load( + path="/nonexistent.toml", + overrides={"users_file": str(tmp_path / "users.toml")}, + ) + cfg._data["server"]["env"] = "staging" # type: ignore[index] + try: + cfg.validate() + except ConfigError as exc: + assert "env" in str(exc).lower() + else: + raise AssertionError("expected ConfigError") + + def test_invalid_base_url_rejected(self, tmp_path) -> None: + from volumen.config import Config, ConfigError + + cfg = Config.load( + path="/nonexistent.toml", + overrides={"users_file": str(tmp_path / "users.toml")}, + ) + cfg._data["site"]["base_url"] = "not-a-url" # type: ignore[index] + try: + cfg.validate() + except ConfigError as exc: + assert "base_url" in str(exc).lower() + else: + raise AssertionError("expected ConfigError") + + def test_production_requires_session_key(self, tmp_path) -> None: + from volumen.config import Config + + (tmp_path / "posts").mkdir() + cfg = Config.load( + path="/nonexistent.toml", + overrides={ + "users_file": str(tmp_path / "users.toml"), + "content": str(tmp_path / "posts"), + }, + ) + cfg._data["server"]["env"] = "production" # type: ignore[index] + cfg._data["admin"]["session_key"] = "" # type: ignore[index] + # Validation succeeds (file is allowed) but create_app will refuse. + cfg.validate() + + +# --------------------------------------------------------------------------- +# Session secret validation +# --------------------------------------------------------------------------- + + +class TestSessionSecret: + def test_production_without_session_key_fails(self, config) -> None: + from volumen.app import create_app + from volumen.store import Store + + config._data["server"]["env"] = "production" # type: ignore[index] + config._data["admin"]["session_key"] = "" # type: ignore[index] + store = Store(config.content_dir) + try: + create_app(config, store) + except RuntimeError as exc: + assert "session_key" in str(exc) + else: + raise AssertionError("expected RuntimeError") + + def test_production_with_short_key_fails(self, config) -> None: + from volumen.app import create_app + from volumen.store import Store + + config._data["server"]["env"] = "production" # type: ignore[index] + config._data["admin"]["session_key"] = "tooshort" # type: ignore[index] + store = Store(config.content_dir) + try: + create_app(config, store) + except RuntimeError as exc: + assert "64 bytes" in str(exc) + else: + raise AssertionError("expected RuntimeError") + + +# --------------------------------------------------------------------------- +# Password policy helpers +# --------------------------------------------------------------------------- + + +class TestPasswordPolicyHelpers: + def test_password_error_min_length(self) -> None: + from volumen.auth import password_error + + assert password_error("short", min_length=10) is not None + + def test_password_error_max_length(self) -> None: + from volumen.auth import password_error + + assert password_error("x" * 2000, min_length=10, max_length=1024) is not None + + def test_password_error_empty(self) -> None: + from volumen.auth import password_error + + assert password_error("", min_length=10) is not None + + def test_password_error_common_rejected(self) -> None: + from volumen.auth import password_error + + assert password_error("password", min_length=10) is not None + + def test_password_error_accepts_strong(self) -> None: + from volumen.auth import password_error + + assert password_error("volumen-strong-pw", min_length=10) is None + + +# --------------------------------------------------------------------------- +# Store hardening +# --------------------------------------------------------------------------- + + +class _Upload: + """Fake UploadFile for testing validate_upload.""" + + content_type: str + filename: str + file: io.BytesIO + + def __init__( + self, data: bytes, content_type: str = "image/webp", filename: str = "x.webp" + ) -> None: + self._data = data + self.content_type = content_type + self.filename = filename + self.file = io.BytesIO(data) + self._pos = 0 + + async def read(self, size: int = -1) -> bytes: + if size == -1: + result = self._data[self._pos :] + self._pos = len(self._data) + return result + result = self._data[self._pos : self._pos + size] + self._pos += size + return result + + +class TestStoreHardening: + def test_validate_upload_accepts_webp(self, tmp_content_dir) -> None: + # Build a fake UploadFile-shaped object. + good_webp = b"RIFF" + b"\x10\x00\x00\x00" + b"WEBP" + b"\x00" * 100 + + import asyncio + + from volumen.upload_validation import validate_upload + + async def go() -> tuple[bytes, str]: + return await validate_upload(_Upload(good_webp), max_bytes=10_000) + + data, ext = asyncio.run(go()) + assert ext == ".webp" + assert data == good_webp + + def test_validate_upload_rejects_mime_mismatch(self, tmp_content_dir) -> None: + import asyncio + + from volumen.upload_validation import validate_upload + + async def go() -> None: + try: + await validate_upload( + _Upload(b"nope", content_type="image/png", filename="x.png"), + max_bytes=10_000, + ) + except Exception as exc: + # The detail is JSON-encoded text. + msg = str(exc) + assert "unsupported_format" in msg or "signature" in msg.lower() + else: + raise AssertionError("expected HTTPException") + + asyncio.run(go()) + + def test_store_upload_uses_signature_extension(self, tmp_content_dir) -> None: + from volumen.store import Store + + store = Store(str(tmp_content_dir)) + avif_bytes = b"\x00\x00\x00\x18ftyp" + b"avif" + b"\x00" * 100 + url = store.store_upload("evil.exe", None, bytes_data=avif_bytes) + assert url.endswith(".avif") + assert "evil" not in url + + def test_store_upload_resolves_whitelisted_extension(self, tmp_content_dir) -> None: + from volumen.store import Store + + store = Store(str(tmp_content_dir)) + webp_bytes = b"RIFF" + b"\x10\x00\x00\x00" + b"WEBP" + b"\x00" * 100 + # File name has an unrelated extension; signature wins. + url = store.store_upload("cover.png", None, bytes_data=webp_bytes) + assert url.endswith(".webp") diff --git a/tests/test_server.py b/tests/test_server.py index 3deaada..dc577ae 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -190,9 +190,34 @@ class TestJSONFeed: assert item["authors"] == [{"name": "@jf@example.io"}] def test_json_feed_falls_back_to_site_default_fediverse_creator( - self, client: TestClient, tmp_content_dir: Path + self, tmp_path: Path, tmp_content_dir: Path ) -> None: - pass # Requires a custom config with site.fediverse_creator set + """When a post has no fediverse_creator, fall back to site.fediverse_creator.""" + from fastapi.testclient import TestClient + + from volumen.app import create_app + from volumen.config import Config + from volumen.store import Store + + config_path = tmp_path / "custom.toml" + users_path = tmp_path / "users.toml" + users_path.write_text("") + config_path.write_text( + f'content_dir = "{tmp_content_dir}"\n' + f'users_file = "{users_path}"\n' + "\n[site]\n" + 'fediverse_creator = "@siteowner@example.social"\n' + ) + custom_config = Config.load(path=str(config_path)) + custom_store = Store(str(tmp_content_dir), default_lang="en") + custom_app = create_app(custom_config, custom_store) + custom_client = TestClient(custom_app) + + response = custom_client.get("/api/volumen/feed.json") + assert response.status_code == 200 + item = response.json()["items"][0] + assert "authors" in item + assert item["authors"] == [{"name": "@siteowner@example.social"}] class TestSitemap: diff --git a/tests/test_users.py b/tests/test_users.py index 898d042..add0812 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -11,7 +11,7 @@ class TestBootstrap: def test_bootstrap_user_from_hash(self, users_path: str, bootstrap_hash: str) -> None: users = Users(path=users_path, bootstrap_hash=bootstrap_hash) assert len(users.all) == 1 - assert users.authenticate("admin", "secret") is not None + assert users.authenticate("admin", "volumen-test-password") is not None def test_bootstrap_user_is_admin(self, users_path: str, bootstrap_hash: str) -> None: users = Users(path=users_path, bootstrap_hash=bootstrap_hash) @@ -34,13 +34,13 @@ class TestFind: class TestAuthenticate: def test_authenticate_correct_password(self, users: Users) -> None: - assert users.authenticate("admin", "secret") is not None + assert users.authenticate("admin", "volumen-test-password") is not None def test_authenticate_wrong_password(self, users: Users) -> None: assert users.authenticate("admin", "nope") is None def test_authenticate_nonexistent_user(self, users: Users) -> None: - assert users.authenticate("nobody", "secret") is None + assert users.authenticate("nobody", "volumen-test-password") is None class TestAdd: @@ -209,9 +209,9 @@ class TestPersistence: def test_password_survives_persistence(self, users_path: str) -> None: users = Users(path=users_path) - users.add("alice", "secret123") + users.add("alice", "alice-test-password") fresh = Users(path=users_path) - assert fresh.authenticate("alice", "secret123") is not None + assert fresh.authenticate("alice", "alice-test-password") is not None assert fresh.authenticate("alice", "wrong") is None def test_mtime_cache_invalidation(self, users_path: str) -> None: diff --git a/tools/license-audit.py b/tools/license-audit.py new file mode 100644 index 0000000..40802d1 --- /dev/null +++ b/tools/license-audit.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Audit ``uv.lock`` for license compliance. + +Parses the lockfile (TOML) and reports each package's ``license`` field. +The tool is intentionally read-only — it never modifies ``uv.lock``. +Permissive licenses (MIT, BSD-2/3-Clause, Apache-2.0, ISC, MPL-2.0, +PSF-2.0, etc.) are flagged as OK; everything else is reported as a +warning. Unknown or missing licenses are also reported as warnings. + +Usage:: + + uv run python tools/license-audit.py +""" + +from __future__ import annotations + +import sys +import tomllib +from pathlib import Path +from typing import Any + +LOCK_PATH = Path(__file__).resolve().parent.parent / "uv.lock" + +PERMISSIVE_LICENSES: frozenset[str] = frozenset( + { + # SPDX expressions (case-insensitive) + "mit", + "mit-0", + "apache-2.0", + "apache-2.0-with-clauses", + "bsd-2-clause", + "bsd-3-clause", + "isc", + "mpl-2.0", + "python-2.0", + "psf-2.0", + "zlib", + "cc0-1.0", + "cc-by-4.0", + "unlicense", + "0bsd", + "blueoak-1.0.0", + "lgpl-2.1", + "lgpl-3.0", + } +) + + +def main() -> int: + if not LOCK_PATH.exists(): + print(f"error: {LOCK_PATH} not found", file=sys.stderr) + return 2 + with LOCK_PATH.open("rb") as fh: + data: dict[str, Any] = tomllib.load(fh) + packages: list[dict[str, Any]] = list(data.get("package", [])) + if not packages: + print("error: no [[package]] entries in uv.lock", file=sys.stderr) + return 2 + + warnings_count = 0 + for pkg in packages: + name = pkg.get("name", "?") + version = pkg.get("version", "?") + license_field = pkg.get("license") + if license_field is None: + # uv stores a separate ``[[package.license]]`` table only for + # SPDX-refs; for many packages the field is absent. + print(f"WARN {name}=={version}: license field missing") + warnings_count += 1 + continue + text = str(license_field).strip().lower() + # Compound expressions like ``MIT OR Apache-2.0`` are accepted when + # any single token is permissive. + tokens = { + tok.strip(" ()") + for tok in text.replace(" and ", " ").replace(" or ", " ").split() + } + if tokens & PERMISSIVE_LICENSES: + print(f"OK {name}=={version}: {text}") + else: + print(f"WARN {name}=={version}: {text}") + warnings_count += 1 + print(f"\nAudited {len(packages)} packages; {warnings_count} warning(s).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index 2785b50..9f8391b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,11 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.12" -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version < '3.13'", -] +requires-python = ">=3.14" [[package]] name = "annotated-doc" @@ -31,7 +26,6 @@ version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ @@ -68,6 +62,45 @@ 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.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + [[package]] name = "detect-installer" version = "0.1.0" @@ -174,38 +207,6 @@ version = "0.11.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/03/0f/0aeb3fc50046617702acc0078b277b58367fd62eb727b9ec733ae0e8bbcc/fastar-0.11.0.tar.gz", hash = "sha256:aa7f100f7313c03fdb20f1385927ba95671071ba308ad0c1763fef295e1895ce", size = 70238, upload-time = "2026-04-13T17:11:17.143Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/06/a5773706afc8bd496769786590bbc56d2d0ee419a299cc12ea3f5717fcf3/fastar-0.11.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3c51f1c2cdddbd1420d2897ace7738e36c65e17f6ae84e0bfe763f8d1068bb97", size = 708394, upload-time = "2026-04-13T17:09:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a6/d5e2a4e48495616440a21eed07558219ca90243ad00b0502586f95bd4833/fastar-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0d9d6b052baf5380baea866675dab6ccd04ec2460d12b1c46f10ce3f4ee6a820", size = 628417, upload-time = "2026-04-13T17:09:42.145Z" }, - { url = "https://files.pythonhosted.org/packages/ab/69/9816d69ac8265c9e50456637a487ccfb7a9c566efd9dbcd673df9c2558c2/fastar-0.11.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd2f05666d4df7e14885b5c38fefd92a785917387513d33d837ff42ec143a22f", size = 863950, upload-time = "2026-04-13T17:09:11.506Z" }, - { url = "https://files.pythonhosted.org/packages/5b/0d/f88daad53aff2e754b6b5ff2a7113f72447a34f6ef17cc23ca99988117b7/fastar-0.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e6e74aba1ae77ca4aedcaf1697cd413319f4c88a5ccbe5b42c709517c5097e", size = 760737, upload-time = "2026-04-13T17:07:55.958Z" }, - { url = "https://files.pythonhosted.org/packages/2f/a6/82ef4ecd969d50d92ed3ed9dbd8fe77faa24be5e5736f716edc9f4ce8d62/fastar-0.11.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:38ef77fe940bbc9b37a98bd838727f844b11731cd39358a2640ff864fb385086", size = 757603, upload-time = "2026-04-13T17:08:10.623Z" }, - { url = "https://files.pythonhosted.org/packages/03/35/50249f0d827251f8ac511495e2eacccebda80a00a0ad73e9615b8113b84f/fastar-0.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8955e61b32d6aff82c983217abf80933fd823b0e727586fc72f08043d996fd59", size = 923952, upload-time = "2026-04-13T17:08:25.526Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d8/faee41659e9c379d906d24eaee6d6833ac8cfef0a5df480e5c2a8d3efb33/fastar-0.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:483532442cdb08fbff0169510224eae0836f2f672cea6aacb52847d90fefdc46", size = 816574, upload-time = "2026-04-13T17:08:56.076Z" }, - { url = "https://files.pythonhosted.org/packages/22/47/0448ea7992b997dad2bf004bfd98eca74b5858630eae080b50c7b17d9ddc/fastar-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef5a6071121e05d8287fc75bccb054bcbac8bb0501200a0c0a8feeace5303ea4", size = 819382, upload-time = "2026-04-13T17:09:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/33/ef/0d63eb43586831b7a6f8b22c4d77125a7c594423af1f4f090fa9541b9b40/fastar-0.11.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e45e598af5afe8412197d4786efd6cf29be02e7d3d4f6a3461149eae5d7e94f1", size = 885254, upload-time = "2026-04-13T17:08:40.9Z" }, - { url = "https://files.pythonhosted.org/packages/01/25/edd584675d69e49a165052c3ee886df1c5d574f3e7d813c990306387c623/fastar-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2e160919b1c47ddb8538e7e8eb4cd527281b40f0bf75110a75993838ef61f286", size = 971239, upload-time = "2026-04-13T17:10:12.997Z" }, - { url = "https://files.pythonhosted.org/packages/a5/37/e8bb24f506ba2b08fbaf36c5800e843bd4d542954e9331f00418e2d23349/fastar-0.11.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4bb4dc0fc8f7a6807febcebce8a2f3626ba4955a9263d81ecc630aad83be84c0", size = 1035185, upload-time = "2026-04-13T17:10:30.207Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bf/be753736296338149ee4cb3e92e2b5423d6ba17c7b951d15218fd7e99bbf/fastar-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4ec95af56aa173f6e320e1183001bf108ba59beaf13edd1fc8200648db203588", size = 1072191, upload-time = "2026-04-13T17:10:47.072Z" }, - { url = "https://files.pythonhosted.org/packages/d2/cd/a81c1aaafb5a22ce57c98ae22f39c89413ed53e4ee6e1b1444b0bd666a6c/fastar-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:136cf342735464091c39dc3708168f9fdeb9ebea40b1ead937c61afaf46143d9", size = 1028054, upload-time = "2026-04-13T17:11:04.293Z" }, - { url = "https://files.pythonhosted.org/packages/ec/88/1ce4eed3d70627c95f49ca017f6bbbf2ddcc4b0c601d293259de7689bc20/fastar-0.11.0-cp312-cp312-win32.whl", hash = "sha256:35f23c11b556cc4d3704587faacbc0037f7bdf6c4525cd1d09c70bda4b1c6809", size = 454198, upload-time = "2026-04-13T17:11:45.168Z" }, - { url = "https://files.pythonhosted.org/packages/8f/1d/26ce92f4331cd61a69840db9ca6115829805eec24f285481a854f578e917/fastar-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:920bc56c3c0b8a8ca492904941d1883c1c947c858cd93343356c29122a38f44c", size = 486697, upload-time = "2026-04-13T17:11:31.084Z" }, - { url = "https://files.pythonhosted.org/packages/ed/96/e6eda4480559c69b05d466e7b5ea9170e81fef3795a73e059959a3258319/fastar-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:395248faf89e8a6bd5dc1fd544c8465113b627cb6d7c8b296796b60ebea33593", size = 462591, upload-time = "2026-04-13T17:11:20.577Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d6/3be260037e86fb694e88d47f583bac3a0188c99cee1a6b257ac26cb6b53c/fastar-0.11.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:33f544b08b4541b678e53749b4552a44720d96761fb79c172b005b1089c443ed", size = 707975, upload-time = "2026-04-13T17:09:58.866Z" }, - { url = "https://files.pythonhosted.org/packages/e1/cd/7867aefb1784662554a335f2952c75a50f0c70585ed0d2210d6cc15e5627/fastar-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c1c792447e4a642745f347ff9847c52af39633071c57ee67ed53c157fc3506", size = 628460, upload-time = "2026-04-13T17:09:43.776Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2b/d11d84bdd5e0e377771b955755771e3460b290da5809cb78c1b735ee2228/fastar-0.11.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:881247e6b6eaea59fc6569f9b61447aa6b9fc2ee864e048b4643d69c52745805", size = 863054, upload-time = "2026-04-13T17:09:13.048Z" }, - { url = "https://files.pythonhosted.org/packages/25/39/d3f428b318fa940b1b6e785b8d54fc895dfb5d5b945ef8d5442ffa904fb2/fastar-0.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:863b7929845c9fec92ef6c8d59579cf46af5136655e5342f8df5cebe46cab06c", size = 760247, upload-time = "2026-04-13T17:07:57.396Z" }, - { url = "https://files.pythonhosted.org/packages/9e/04/03949aee82aabb8ede06ac5a4a5579ffaf98a8fe59ce958494508ff15513/fastar-0.11.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:96b4a57df12bf3211662627a3ea29d62ecb314a2434a0d0843f9fc23e47536e5", size = 756512, upload-time = "2026-04-13T17:08:12.415Z" }, - { url = "https://files.pythonhosted.org/packages/3f/0c/2ca1ae0a3828ca51047962d932b80daca2522db73e8cb9d040cb6ebe28d5/fastar-0.11.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceef1c2c4df7b7b8ebd3f5d718bbf457b9bbdf25ce0bd07870211ec4fbd9aff4", size = 922183, upload-time = "2026-04-13T17:08:27.187Z" }, - { url = "https://files.pythonhosted.org/packages/65/68/7fe808b1f73a68e686f25434f538c6dc10ef4dfb3db0ace22cd861744bf8/fastar-0.11.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8e545918441910a779659d4759ad0eef349e935fbdb4668a666d3681567eb05", size = 816394, upload-time = "2026-04-13T17:08:57.657Z" }, - { url = "https://files.pythonhosted.org/packages/1f/17/07d086080f8a83b8d7966955e29bcdbd6a060f5bd949dc9d5abd3658cead/fastar-0.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28095bb8f821e85fc2764e1a55f03e5e2876dee2abe7cd0ee9420d929905d643", size = 818983, upload-time = "2026-04-13T17:09:28.46Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e2/2c4edf0910af2e814ff6d65b77a91196d472ca8a9fb2033bd983f6856caa/fastar-0.11.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0fafb95ecbe70f666a5e9b35dd63974ccdc9bb3d99ccdbd4014a823ec3e659b5", size = 884689, upload-time = "2026-04-13T17:08:42.763Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/04fdcbd6558e60de4ced3b55230fac47675d181252582b2fcec3c74608e5/fastar-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:af48fed039b94016629dcdad1c95c90c486326dd068de2b0a4df419ee09b6821", size = 970677, upload-time = "2026-04-13T17:10:15.124Z" }, - { url = "https://files.pythonhosted.org/packages/df/b3/2b860a9658550167dbd5824c85e88d0b4b912bf493e42a6322544d6e483d/fastar-0.11.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:74cd96163f39b8638ab4e8d49708ca887959672a22871d8170d01f067319533b", size = 1034026, upload-time = "2026-04-13T17:10:32.318Z" }, - { url = "https://files.pythonhosted.org/packages/b7/9b/fa42ea1188b144bac4b1b60753dfd449974a4d5eda132029ee7711569f94/fastar-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4e8b993cb5613bab495ed482810bedc0986633fcb9a3b55c37ec88e0d6714f6a", size = 1071147, upload-time = "2026-04-13T17:10:48.833Z" }, - { url = "https://files.pythonhosted.org/packages/95/c8/d2e501556dca9f1fbc9246111a31792fb49ad908fa4927f34938a97a3604/fastar-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfe39d91fc28e37e06162d94afe01050220edb7df554acb5b702b5503e564816", size = 1028377, upload-time = "2026-04-13T17:11:06.374Z" }, - { url = "https://files.pythonhosted.org/packages/db/33/5f11f23eca0a569cd052507bc45dda2e5468697f8665728d25be44120f7d/fastar-0.11.0-cp313-cp313-win32.whl", hash = "sha256:c5f63d4d99ff4bfb37c659982ec413358bdee747005348756cc50a04d412d989", size = 454089, upload-time = "2026-04-13T17:11:46.821Z" }, - { url = "https://files.pythonhosted.org/packages/da/2f/35ff03c939cba7a255a9132367873fec6c355fd06a7f84fedcbaf4c8129f/fastar-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8690ed1928d31ded3ada308e1086525fb3871f5fa81e1b69601a3f7774004583", size = 486312, upload-time = "2026-04-13T17:11:32.86Z" }, - { url = "https://files.pythonhosted.org/packages/ef/71/ee9246cbfcbfd4144558f35e7e9a306ffe0a7564730a5188c45f21d2dab8/fastar-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:d977ded9d98a0719a305e0a4d5ee811f1d3e856d853a50acb8ae833c3cd6d5d2", size = 461975, upload-time = "2026-04-13T17:11:22.589Z" }, { url = "https://files.pythonhosted.org/packages/7a/cd/3644c48ecac456f928c12d47ec3bed36c36555b17c3859856f1ff860265d/fastar-0.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:71375bd6f03c2a43eb47bd949ea38ff45434917f9cdac79675c5b9f60de4fa73", size = 707860, upload-time = "2026-04-13T17:10:00.371Z" }, { url = "https://files.pythonhosted.org/packages/69/ca/dee04476ae3626b2b040a60ad84628f77e1ffd8444232f2426b0ca1e0d7e/fastar-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eddfd9cab16e19ae247fe44bf992cb403ccfe27d3931d6de29a4695d95ad386c", size = 628216, upload-time = "2026-04-13T17:09:45.355Z" }, { url = "https://files.pythonhosted.org/packages/dc/5e/9395c7353d079cb4f5be0f7982ce0dc9f2e7dec5fd175eef466729d6023a/fastar-0.11.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c371f1d4386c699018bb64eb2fa785feacf32785559049d2bb72fe4af023f53", size = 864378, upload-time = "2026-04-13T17:09:14.611Z" }, @@ -262,26 +263,25 @@ 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 = "httpcore2" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/fe/6a3f9f1a8bb8733326140737446aaf72fddb8b54b8f202302f5c84960613/httpcore2-2.7.0.tar.gz", hash = "sha256:6dc0fedf329a52a990930a5579edfebaea81118ea700ea0dd7de2b5e5be49efc", size = 65593, upload-time = "2026-07-14T20:40:01.111Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/6c/62e2e279e63fc4f7a5ee841ef13175a8bbc613f258e9dcc186e9de803a42/httpcore2-2.7.0-py3-none-any.whl", hash = "sha256:1452f589fe23f55b44546cd884294c41a29330af902bc0b71a761fd52d18f92b", size = 81506, upload-time = "2026-07-14T20:39:58.053Z" }, +] + [[package]] name = "httptools" version = "0.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, - { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, - { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, - { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, - { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, - { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, @@ -313,6 +313,21 @@ 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 = "httpx2" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/4a/129b2e21b90ac2985d3928d96792bccc39bc6dfe796c5eee2d8ec06d4105/httpx2-2.7.0.tar.gz", hash = "sha256:8b30709aed5c8465b0dd3b95c09ce301c8f79e7e7a2d00ab0af551e0d0375b07", size = 94487, upload-time = "2026-07-14T20:40:02.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/b8/c341bba6411bdfda786020343c47a75ef472f6085caf82391b142b1a3ad9/httpx2-2.7.0-py3-none-any.whl", hash = "sha256:ed2a2719c696789e09493bd8e2bec3d8bd925cc6e26b68389ec25ade132f7bf4", size = 90234, upload-time = "2026-07-14T20:39:59.531Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -379,39 +394,6 @@ 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/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, @@ -445,6 +427,40 @@ 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 = "nh3" +version = "0.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/1b/ef84624f14954d270f74060a19fc550dd4f06656399447569afb584d8c06/nh3-0.3.6.tar.gz", hash = "sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7", size = 24684, upload-time = "2026-06-22T00:47:02.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/3e/6506aa4f23dc7b7993a2d0a45dca3ce864ec48380adfe15a173e643c63e8/nh3-0.3.6-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2411e8c3cee81a1ddd62c2a5d50585c28aa5566d373ad1db92536b95ddb24ef2", size = 1421679, upload-time = "2026-06-22T00:46:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e1/e96e7864a7a53bd6b6fab7e9632467382a2a2c1f3fed951918ad131542fb/nh3-0.3.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e196fa70c2ff2eb4de7d3df3108f8f358c1d69dff20d45b11f20a5aa227ffb6d", size = 792570, upload-time = "2026-06-22T00:46:22.179Z" }, + { url = "https://files.pythonhosted.org/packages/59/62/5b6108bedaef2b2637fed04c87bdbcb5967b9961758b41f0e466ef22a022/nh3-0.3.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:34d2b0d934156b87ee114f599a3ba9b8b9e17b5d79652ba3a13fa50903de965e", size = 842243, upload-time = "2026-06-22T00:46:23.801Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4a/526f199626bfcb496bc01a268051b44737962005553b158e985ed7e64865/nh3-0.3.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2f14b7ae1fca99c4a66c981aac3974e7fbc1ca30a12673d223ae1df76680917", size = 1001468, upload-time = "2026-06-22T00:46:25.481Z" }, + { url = "https://files.pythonhosted.org/packages/49/09/0d8e3101636d9ad88cdefb2914e764cb8e876ebdbb4286bfc251277d9c67/nh3-0.3.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:889932a97fb4abb6f95fef1914c0d269ebfb60011e67121c1163059b9449dbb4", size = 1082933, upload-time = "2026-06-22T00:46:27.15Z" }, + { url = "https://files.pythonhosted.org/packages/09/a1/ea83abe738a3fbaa203dfdb836ca7cbab0e7e9609faaee4fe1d4652599c0/nh3-0.3.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:edb2b4a1a27523e6cc7c417f8d21ce3d005243548b93e56b762b66b0c7f589f9", size = 1043120, upload-time = "2026-06-22T00:46:28.89Z" }, + { url = "https://files.pythonhosted.org/packages/66/69/0654482b8635012fbae67826bd6c381abb05d841ac7388b9b4666300fdad/nh3-0.3.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43bc1ed3fa0716295fabee29ba42b2667e4a51d140b0a68e092170a765474fa6", size = 1023824, upload-time = "2026-06-22T00:46:30.453Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/1f7285ffadc8307c4dbeb08d21b920536d5117785056d1079e998c4dfa44/nh3-0.3.6-cp314-cp314t-win32.whl", hash = "sha256:597a8e843bea00b2eb5520658dc24a9bb032e7fc9e7c2c0c4cd29420220c9796", size = 599253, upload-time = "2026-06-22T00:46:32.072Z" }, + { url = "https://files.pythonhosted.org/packages/36/ea/5542f3c45da4c00290d9d67a65e996702e23e613c4b627de3e09cb9fe357/nh3-0.3.6-cp314-cp314t-win_amd64.whl", hash = "sha256:4713502748f564fee0633b37b3403783ce0a3af3a3d148ad91025a5bdadb7bc6", size = 612553, upload-time = "2026-06-22T00:46:33.53Z" }, + { url = "https://files.pythonhosted.org/packages/66/35/26bd47e6af5915a628281dccdac354ddf4e32f7397047894270acd8c9870/nh3-0.3.6-cp314-cp314t-win_arm64.whl", hash = "sha256:69bbb92865a693d909db3a700d3c01537533844d0948c1e9323561ce06ecda41", size = 595151, upload-time = "2026-06-22T00:46:34.878Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ab/a7653bce9a3b204be6a6931767a9e23595807bb84790ce6685e4d7e5bd08/nh3-0.3.6-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a43ebd7543555c3ac1bc353023d0794e75cb76f6f18f19c32e95441496c0cc25", size = 1443564, upload-time = "2026-06-22T00:46:36.66Z" }, + { url = "https://files.pythonhosted.org/packages/41/21/e1084ab18eb589506335c7c7576f2d4643e9a0c0e33983ef0e549a256b96/nh3-0.3.6-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1b160831c9cdb06a6c79c2f9cdb11386602938f9af260d1c457a85add4f6f69", size = 838002, upload-time = "2026-06-22T00:46:38.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/94/f48d08e6f72a406300fa11d8acd929fea1a80d4bf750fa292cb10785f126/nh3-0.3.6-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14bf7982e7a77c0c775634c29c07ce08b38a046df73e1c1f139b3e82f18a38e", size = 823045, upload-time = "2026-06-22T00:46:39.495Z" }, + { url = "https://files.pythonhosted.org/packages/25/bb/431615ba1d1d3eb63cde0f974f2114edf863a8a3f6049a12fed23fc241d3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:44673b27010051ab5a5e438a86ec31bbda61d4a77d7e900af6b7be3037c1abae", size = 1093171, upload-time = "2026-06-22T00:46:41.21Z" }, + { url = "https://files.pythonhosted.org/packages/0e/24/a0d80182a18919665fefd19c1c06f1d1df1c9a6455d0252de40c034a0bc3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6b7beece07525dc6e6b0fc2f104442de2ba328360ad00e50cbe2e1fd620447d", size = 1049217, upload-time = "2026-06-22T00:46:42.804Z" }, + { url = "https://files.pythonhosted.org/packages/0a/13/6f1e302ca674ac74362e150848ad56a1be5145391204f74facdb8e94df12/nh3-0.3.6-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:455469a29951edc92bc48b47ac2281c3f2609e6c4f6a047056449f8c2c23facf", size = 917372, upload-time = "2026-06-22T00:46:44.495Z" }, + { url = "https://files.pythonhosted.org/packages/5b/67/314f6151bad77a93d751978a344033e1fc890822f05f0416079338e34231/nh3-0.3.6-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:905f877dc66dd7aea4a76e54bcb26acb5ff8216f720c0017ccf63e0e6035698e", size = 806699, upload-time = "2026-06-22T00:46:45.99Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a6/bfaa00046e58603507dcfc266c4778e3ab7adf68a5dedd73b6274b8d9314/nh3-0.3.6-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:25c733bee928530556b1db0ea46c52cf5aa686146e38e60a6fc7cb801ef91cec", size = 835165, upload-time = "2026-06-22T00:46:47.617Z" }, + { url = "https://files.pythonhosted.org/packages/30/a8/fb2c38845efb703a9173bffdfc745fc64d2b0e55cfc73a3647d2f028250c/nh3-0.3.6-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f90d9a0cfdbee218994fdaaeeb5a0fde62d08f35e4eef0378ec1e2200172fd0", size = 858282, upload-time = "2026-06-22T00:46:49.276Z" }, + { url = "https://files.pythonhosted.org/packages/68/17/06e72a18ee9b572914447338237ca7eb164c0df901f141bc10d1282247a2/nh3-0.3.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:82ca5bf427ad1b216b65ede1a2e2d87dc49bec417ceba0f297213107d3cd9d78", size = 1014328, upload-time = "2026-06-22T00:46:51.026Z" }, + { url = "https://files.pythonhosted.org/packages/11/f9/3966c61455668c08853bf5e33b4bed93c421f3194ce4de896dc248d6f6ce/nh3-0.3.6-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438", size = 1098207, upload-time = "2026-06-22T00:46:52.674Z" }, + { url = "https://files.pythonhosted.org/packages/19/d3/479cb4ae440424825735d60525b53e3c77fd60fd6e6afc0e984f00eb0178/nh3-0.3.6-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:082675ff87b9385ec430ffe6d5847ba7456cc39b73720cd4add472f9f4cffd56", size = 1056961, upload-time = "2026-06-22T00:46:54.335Z" }, + { url = "https://files.pythonhosted.org/packages/17/0c/6cdb5ee1e127be50dc8391e54bddc1f64e87bf4bfad0c55633320e2e02db/nh3-0.3.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36d06341bd501240d320f5942481ed5e6846136b666e1ba4faf802b78ebc875f", size = 1033829, upload-time = "2026-06-22T00:46:56.258Z" }, + { url = "https://files.pythonhosted.org/packages/e9/55/9de666ad975d6ccd77d799ea0add55ee2347aa81286ce21b2a97c070746b/nh3-0.3.6-cp38-abi3-win32.whl", hash = "sha256:5276ef17bdba9ad8040575c74072008b13aae429436e9d0429e718bb5f90f4da", size = 609081, upload-time = "2026-06-22T00:46:57.665Z" }, + { url = "https://files.pythonhosted.org/packages/82/fa/2b5d684e3edf1e81bfd02d298c78c3e3da77ca1d8a2be3183a79544a7548/nh3-0.3.6-cp38-abi3-win_amd64.whl", hash = "sha256:f338ac7d594c067679f1e99b4f5ec3906842979560f9d8f15d6bdfa39a353b10", size = 624461, upload-time = "2026-06-22T00:46:59.163Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e5/7cafee2f0413ca4cb0ef3bd111e94d408a48810008b283ad8aee00dd1809/nh3-0.3.6-cp38-abi3-win_arm64.whl", hash = "sha256:69f365963f63a1e9bff53bdbb3c542c7c2efed3e163c9d5d83a772a2ac468c21", size = 603060, upload-time = "2026-06-22T00:47:00.596Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -492,36 +508,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, - { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, - { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, - { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, - { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, - { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, - { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, - { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, - { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, - { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, - { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, - { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, - { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, - { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, - { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, - { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, - { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, - { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, - { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, - { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, @@ -552,10 +538,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, - { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] [[package]] @@ -623,6 +605,20 @@ 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-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 = "python-dotenv" version = "1.2.2" @@ -647,26 +643,6 @@ 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/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, @@ -720,38 +696,6 @@ version = "0.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c9/77/6ba90ab4a538d3ec244329c57c4d26a78c8313ea6fa72c8768d46f11c1c9/rignore-0.8.0.tar.gz", hash = "sha256:2e5ad6b19834f04a877d26fe863fd77ed851ed4019fdca097fb1b744311e3562", size = 55358, upload-time = "2026-07-17T19:01:21.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/7e/0d270c1ed723b82bea8ccd504185acb5d71830975260e8de02af7acff728/rignore-0.8.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a5e285ec58b3b66284a7f48805e4db0ea948370deb484a4935b147187ecf1e25", size = 847184, upload-time = "2026-07-17T18:58:31.348Z" }, - { url = "https://files.pythonhosted.org/packages/ff/dc/841941f8b0883a8038f9d540607238c456df20e98243a61142fd15699806/rignore-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:367d3cf401b477a5ba7eb05b5b94c491b0d704507d9eaf80378a8d843fe00674", size = 816590, upload-time = "2026-07-17T18:58:32.571Z" }, - { url = "https://files.pythonhosted.org/packages/57/53/9e047a6cd95b553519703350f7a3530ddd31d309c9ade1ec913db5882f74/rignore-0.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d49870c032abcc28db2210ad92b3bd5706ec792ab25c9295a6c536a4b92226ce", size = 884130, upload-time = "2026-07-17T18:58:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/07/5a/ae444f30dfa47716ccadf9ed9512736494c0bf5222f330845a637f1c569c/rignore-0.8.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d50632823c273ee19212fd939b04e9b55d5a6e0c9b998fadef6dc9a0c2f6aecc", size = 857230, upload-time = "2026-07-17T18:58:35.32Z" }, - { url = "https://files.pythonhosted.org/packages/ba/67/b2ddfbf5a42a8ea89e6a0330189f7f76f9113e0d8c6a4c68b68ff68f139c/rignore-0.8.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3337e69856ba18c9d079e0ce8b342dc256f48585ae3e1c7aac67682b672e83a", size = 1133331, upload-time = "2026-07-17T18:58:36.895Z" }, - { url = "https://files.pythonhosted.org/packages/8e/76/291be16f7260ac87dedab41394f7738834bb1cd05d6e1b878eea8d598520/rignore-0.8.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c1264c48aaff09c6431b428144e9e9fbd0d7864673f7bd752b51d2409971275e", size = 912520, upload-time = "2026-07-17T18:58:38.178Z" }, - { url = "https://files.pythonhosted.org/packages/0c/99/cd0df2def95ecfa93c781431ce7c58ea52f90742550ff327ed0f1f715f14/rignore-0.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8912e3cb4034b972ba75c9ecabb79bc41a1076cdf726e53019e45b71b82da56", size = 927109, upload-time = "2026-07-17T18:58:40Z" }, - { url = "https://files.pythonhosted.org/packages/e4/58/ce8af7214b903a2f86c3223dabd23e03a0c499df6559be5c9e2b15b4344c/rignore-0.8.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:43b20be0f1c8ae3f3a2575dbee4a38bd406608855f6e79cde8fda1c6bff9e203", size = 892314, upload-time = "2026-07-17T18:58:41.391Z" }, - { url = "https://files.pythonhosted.org/packages/47/3f/e8f4ec6cdf04d0cb9915590679771b55a6f579a7478704d1e6be45a2f792/rignore-0.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:02cf38d96b6f54b69719b726877e308b34ac2c9c9f636dc8872ede2ff6825579", size = 962528, upload-time = "2026-07-17T18:58:42.736Z" }, - { url = "https://files.pythonhosted.org/packages/58/57/d10a43221644177f776d658ed9a03204e49d9bfd6805fbece841ea6720ac/rignore-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5c820d3fa597ee419f3643f8c098a51249a868ca8515296a56a1505c01a8083d", size = 1060799, upload-time = "2026-07-17T18:58:44.279Z" }, - { url = "https://files.pythonhosted.org/packages/a9/0e/d769dfa933861dd469c9158ffc9bc2cb3bc6d46f88000fb6f41cf2905a3a/rignore-0.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e3e863b2cb1db481384bd43a25730ef00f7eccdd49c82a0e1d481a6412dc2653", size = 1132462, upload-time = "2026-07-17T18:58:45.583Z" }, - { url = "https://files.pythonhosted.org/packages/c1/3d/57fc6264ebf8d9b95850589899e6192d9f92b67ea48e56fc32969b75474a/rignore-0.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90cc363aedc7a93b4b15933f2104bfcae1c6c1635a9bc479665cae83044577d9", size = 1139679, upload-time = "2026-07-17T18:58:46.928Z" }, - { url = "https://files.pythonhosted.org/packages/fa/be/24b12a8464e19d348aeb388267f897ede0fae6618052c3148b747acae214/rignore-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:303a9fd02d3612d15e6dc3474ae7d3eae1d07b668b7b4943fd4df305be4d2f76", size = 1138125, upload-time = "2026-07-17T18:58:48.42Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8d/89f7cba3491164c04f8a64056ca973494536ffdf48bfba41565f54dfa122/rignore-0.8.0-cp312-cp312-win32.whl", hash = "sha256:e17a0914378fa15d1e29effce4b39fd2031f253e52d307ff4b71c443d1d5d30e", size = 637663, upload-time = "2026-07-17T18:58:49.76Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ff/823a5ead8bba0a2054cbf2dbef99989204557904ad8578f514bec1b9b4e7/rignore-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:d5c8f1dff84c4d4114f1553564b5d909a0c4c09dbe1b5916a0506d719f1fc3b5", size = 728265, upload-time = "2026-07-17T18:58:51.028Z" }, - { url = "https://files.pythonhosted.org/packages/8f/ce/73c919505f9f270ee3e34205ff2dbe0b0d73e945f8c569922acff148bcf4/rignore-0.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:6cdea3f85de8286a38ae75a0f9092cd3afc4d33ce6ee2e3f6005f97f7da9d249", size = 665216, upload-time = "2026-07-17T18:58:52.308Z" }, - { url = "https://files.pythonhosted.org/packages/67/76/fc272f7a61bec353d321f04b6d4d6cf7cb1fe646e0e1bedd1abf187699bd/rignore-0.8.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:178d7a82b6f1dd0378efda2a69252f73240221ea727529ed67be788ad551cccf", size = 847049, upload-time = "2026-07-17T18:58:54.123Z" }, - { url = "https://files.pythonhosted.org/packages/a1/06/99ce87ef61c86670b3ffa1be6131bc73318a541c2d9d00edbfe00fe27b1f/rignore-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0cefccd353ab5f7436457c420a4058daba873552344eb731b124c6d25110fef9", size = 815664, upload-time = "2026-07-17T18:58:55.422Z" }, - { url = "https://files.pythonhosted.org/packages/1c/88/ecb7451631e493ce8d41d0cfa287a9aca891ca2b738ded4dd8be83cf1476/rignore-0.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd72c6eeec68a3354582e11168c702c55ff543af7a3e8b3f47bed63334d0a330", size = 884344, upload-time = "2026-07-17T18:58:56.82Z" }, - { url = "https://files.pythonhosted.org/packages/e8/fe/24fe7e5f36c0ea0043017ee92ec6bbb980a6ca356e5fbe0ab3320a6c5482/rignore-0.8.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3e0f17f611ab32a505f32f3d6b6a08cc0e9ef4c596df07a8ebbc99225d07ca8b", size = 857024, upload-time = "2026-07-17T18:58:58.444Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e4/267814f4d4a96208f2d676d3e070b73d386a58dd45bad2bc2fd7d9babb27/rignore-0.8.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:828630c1710e3d21f79dd90b1939b1b8da4a49bdab857ce7fc11b58dd5ce509e", size = 1133041, upload-time = "2026-07-17T18:59:00.036Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b6/e6da8ed3ee26134bb1d08a5d181012c16a4bfeb4dfa5f461edf3d19dff95/rignore-0.8.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b074ea1fece686da847d2dc7a7f452af9f7a82e7bc9181f196104f1c0df4ebd", size = 912935, upload-time = "2026-07-17T18:59:01.333Z" }, - { url = "https://files.pythonhosted.org/packages/ed/91/4785a1673aa92b34bd2625b2c836debadfee4f6e394b273c86060f68b05b/rignore-0.8.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cb7dfc95cae2deac47e5f75b6d8df041351664907e412ac39c3af44dae47ce0", size = 927344, upload-time = "2026-07-17T18:59:02.744Z" }, - { url = "https://files.pythonhosted.org/packages/a7/8e/07d11d91c73f3723024ba5a1034818f6232f3c27c7bf194f2bc1eabb449c/rignore-0.8.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:80d3e468c2cd24be93eca4953ca7ea73dfaacf97ba674c54c61c8aad877d2bc3", size = 892736, upload-time = "2026-07-17T18:59:04.049Z" }, - { url = "https://files.pythonhosted.org/packages/5d/85/97f07d4c6737e6ae7dbef37b7eda5b7de1442167dc696483f7e002e2c8a9/rignore-0.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3ac6b6ce80d24015055d07b47a5157b8cb9b207bf9d4963f401b2c08e0263eb", size = 962660, upload-time = "2026-07-17T18:59:05.443Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6f/524ba5a14d2e297edc3ea5baece5eec38bdb8a40e717b86aecaa3b84aa48/rignore-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f6200325937f8c6a24dab351e259fce6ea8fd744888118791074796f4041cb0a", size = 1060679, upload-time = "2026-07-17T18:59:06.867Z" }, - { url = "https://files.pythonhosted.org/packages/a6/84/699fdb9a0380ff1dc4910d7cb5d730dd823ba7b525d1c92cdb57023545cd/rignore-0.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:51b33030b83526e4284f2e115e04ff80f0ed8d992cd5bf389c275aaac534b829", size = 1132220, upload-time = "2026-07-17T18:59:08.211Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ac/c840f5992ece10f19f27607603b23864dd7a5fad6acb90eb43c52f02879e/rignore-0.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:48aa8acf2217d77cb4784fd5f9a95aaa73a85b9a337b7511679b1bc73f64b7d9", size = 1139449, upload-time = "2026-07-17T18:59:09.752Z" }, - { url = "https://files.pythonhosted.org/packages/f9/1b/3f6f2a0a1b23e639f637394821c68bfb3a435349ed5ecd562af08c9a3e4f/rignore-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bc453de8490c76ab3905a18116d0f6555ee94540e1e69cca49e6675e9ca05888", size = 1138480, upload-time = "2026-07-17T18:59:11.391Z" }, - { url = "https://files.pythonhosted.org/packages/98/b2/f5478b3850ad894f1480b36028b54868ba9c0069b7b13f4d78ff0a0391f7/rignore-0.8.0-cp313-cp313-win32.whl", hash = "sha256:0f03e964e7845583b1c344098d6e1b178e0e53ba7e496c8f148966b74dd2e5a9", size = 637473, upload-time = "2026-07-17T18:59:12.748Z" }, - { url = "https://files.pythonhosted.org/packages/1a/3e/53ab47642c45222e4d47877d62e80ea79c0cf0b989a1b89f04244f14d6a4/rignore-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:71a00835b08987c86d6b7c14661a7a628c868bc649e2afbeb4962167561fbd11", size = 727646, upload-time = "2026-07-17T18:59:14.135Z" }, - { url = "https://files.pythonhosted.org/packages/0d/33/9793162cbe185c05869b41c029c341dca659008b1e55aefd0fd6f8cf67e3/rignore-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:28d20c22758b636936d8ae2a684b4ed2c64deea30410ca203c72cdfb4bbde0f1", size = 664864, upload-time = "2026-07-17T18:59:15.534Z" }, { url = "https://files.pythonhosted.org/packages/b0/ae/b7d365a5d103e527b3d23d11f19ab91ff5813a2509a236d8f56f608f03ff/rignore-0.8.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:f7cd98185bb89d11676454df1617f00ca59473fe2c86471c7c7d8c97ed6359f6", size = 846928, upload-time = "2026-07-17T18:59:16.862Z" }, { url = "https://files.pythonhosted.org/packages/bc/4a/736d51756de8557be4ef2837d4c2eb6e3934887f1c04eb6ec1d0198b708d/rignore-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4101def5fdd1459ba107710382c18916c9981f961cad5196533ee4b06295f05b", size = 817297, upload-time = "2026-07-17T18:59:18.213Z" }, { url = "https://files.pythonhosted.org/packages/56/c6/3799fa414d51779b4ffb71fb8fb861f886a592127cd28f88d81b1681dc4a/rignore-0.8.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a67b7f4485dbda470981c8ea7cdf270f2f9139169e86c8b57b9bb4649e79b7f6", size = 885129, upload-time = "2026-07-17T18:59:19.807Z" }, @@ -839,7 +783,6 @@ version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ @@ -855,6 +798,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.27.0" @@ -929,18 +881,6 @@ version = "0.22.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, - { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, - { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, - { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, @@ -964,32 +904,39 @@ dependencies = [ { name = "itsdangerous" }, { name = "jinja2" }, { name = "markdown" }, + { name = "nh3" }, { name = "pymdown-extensions" }, { name = "python-multipart" }, { name = "tomli-w" }, ] -[package.optional-dependencies] +[package.dev-dependencies] dev = [ - { name = "httpx" }, + { name = "httpx2" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "ruff" }, ] [package.metadata] requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.115" }, - { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28" }, { name = "itsdangerous", specifier = ">=2.2" }, { name = "jinja2", specifier = ">=3.1" }, { name = "markdown", specifier = ">=3.7" }, + { name = "nh3", specifier = ">=0.2" }, { name = "pymdown-extensions", specifier = ">=10.14" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, { name = "python-multipart", specifier = ">=0.0.20" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9" }, { name = "tomli-w", specifier = ">=1.2" }, ] -provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx2", specifier = ">=2.7" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-cov", specifier = ">=5" }, + { name = "ruff", specifier = ">=0.9" }, +] [[package]] name = "watchfiles" @@ -1000,45 +947,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, - { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, - { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, - { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, - { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, - { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, - { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, - { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, - { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, - { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, - { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, - { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, - { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, - { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, - { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, - { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, - { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, - { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, - { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, - { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, - { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, - { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, - { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, - { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, - { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, - { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, @@ -1083,40 +991,6 @@ version = "16.1.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, - { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, - { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, - { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, - { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, - { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, - { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, - { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, - { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, - { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, - { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, - { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, - { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, - { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, - { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, - { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, - { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, - { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, - { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, - { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, - { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, - { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, - { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, - { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, - { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" },