diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..abcf0a6 Binary files /dev/null and b/.coverage differ diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 8310d60..0000000 --- a/.editorconfig +++ /dev/null @@ -1,43 +0,0 @@ -# EditorConfig: https://editorconfig.org - -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true -indent_style = space -indent_size = 2 - -[*.{rb,erb,gemspec}] -indent_style = space -indent_size = 2 - -[{Gemfile,Rakefile}] -indent_style = space -indent_size = 2 - -[*.{js,html,css,vue}] -indent_style = space -indent_size = 2 - -[*.{yml,yaml,json,toml}] -indent_style = space -indent_size = 2 - -[*.{rs,cj}] -indent_style = space -indent_size = 4 - -[*.go] -indent_style = tab -indent_size = 4 - -[justfile] -indent_style = tab - -[*.md] -indent_style = space -indent_size = 2 -trim_trailing_whitespace = false diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml deleted file mode 100644 index 98a36ac..0000000 --- a/.forgejo/workflows/release.yml +++ /dev/null @@ -1,129 +0,0 @@ -name: Release - -on: - push: - tags: ["v*"] - -jobs: - release: - 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: Resolve and verify version - id: version - env: - REF: ${{ forgejo.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}" - GEM_VERSION="$(ruby -Ilib -rvolumen/version -e 'print Volumen::VERSION')" - if [ "$VERSION_NO_V" != "$GEM_VERSION" ]; then - echo "ERROR: tag ${VERSION} does not match Volumen::VERSION (${GEM_VERSION})" - exit 1 - fi - - echo "version_no_v=${VERSION_NO_V}" >> "$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" },