diff --git a/.changeset/supply-chain-skill-uv.md b/.changeset/supply-chain-skill-uv.md new file mode 100644 index 0000000000..38cd1582e5 --- /dev/null +++ b/.changeset/supply-chain-skill-uv.md @@ -0,0 +1,5 @@ +--- +'stash': patch +--- + +The `stash-supply-chain-security` skill now lists `uv` among the ecosystems Dependabot updates. The repository's Python SDK has a `uv.lock` for its test dependencies, and a Dependabot entry for it. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e82e6bf246..6f8f185a08 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -249,10 +249,10 @@ updates: # ── Cargo (the root workspace — the stack-* crates) ───────────── # The root workspace (the six stack-* crates and the three node binding - # crates) and the six detached workspaces beside it: the three cargo-fuzz - # crates, the two Go WASI guests and the EQL ORE timing harness's ciphertext - # generator. Each has its own Cargo.lock, and each is its own workspace - # root, so each is listed. + # crates) and the detached workspaces beside it: the three cargo-fuzz + # crates, the two Go WASI guests, the EQL ORE timing harness's ciphertext + # generator, and the Python SDK. Each has its own Cargo.lock, and each is + # its own workspace root, so each is listed. - package-ecosystem: cargo directories: - / @@ -262,9 +262,10 @@ updates: - /languages/golang/encrypt/guest - /languages/golang/auth/guest - /packages/eql/tests/timing/ore_block_256/ctgen + - /languages/python # Lockfile only, like the other two cargo entries. Every one of these - # workspaces shares crates by path with another: the fuzz crates and both - # guests depend on root-workspace crates, the encrypt guest also on + # workspaces shares crates by path with another: the fuzz crates, both + # guests and the Python SDK depend on root-workspace crates, the encrypt guest also on # eql-bindings, and the EQL workspace on stack-encrypt and stack-kms. Those # root crates take `{ workspace = true }` requirements from the root # Cargo.toml, so a manifest edit made for any one of these lockfiles can @@ -369,6 +370,45 @@ updates: update-types: - version-update:semver-major + # ── uv (languages/python — the Python SDK's test and build tools) ── + # The package has no runtime dependency. So uv.lock contains two sets of + # tools: the `test` extra (pytest, attrs, Pydantic, SQLAlchemy, psycopg and + # Alembic), and the `build` dependency group (maturin, the build backend), + # with what they need. `mise run build` installs both with + # `uv sync --locked`. Dependabot's uv parser reads `[dependency-groups]` + # as well as the extras, so maturin is updated with the rest. Both have + # lower bounds and no major upper bound, so a new minor or patch release + # changes only the lock. + - package-ecosystem: uv + directory: /languages/python + # Monthly, matching the other non-npm toolchains. No `day:`, for the + # reason recorded on the cargo entries. + schedule: + interval: monthly + cooldown: + default-days: 7 + open-pull-requests-limit: 3 + labels: + - dependencies + - supply-chain + commit-message: + prefix: "chore" + include: scope + groups: + # One pull request for the set, so the tests run once against all the + # new versions together. + python-test-minor-patch: + patterns: + - "*" + update-types: + - minor + - patch + ignore: + # Major bumps are reviewed and applied manually, not by Dependabot. + - dependency-name: "*" + update-types: + - version-update:semver-major + # ── GitHub Actions ───────────────────────────────────────────── - package-ecosystem: github-actions directory: / diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index dd18c169c1..1f84c0315b 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -1,9 +1,11 @@ name: Fuzz (crates) # libFuzzer fuzzing for the stack crates' untrusted-input parsers (the -# detached `packages/*/fuzz/` crates and the `fuzz:*` mise tasks). Ported from -# cipherstash-suite's `fuzz.yml`, for the six targets in the imported set; the -# suite keeps its three cts-common targets. Two jobs with different roles: +# detached `packages/*/fuzz/` crates and the `fuzz:*` mise tasks), and the +# Python SDK's value conversion (`languages/python/crates/core/fuzz/`). Ported from +# cipherstash-suite's `fuzz.yml`, which keeps its three cts-common targets. +# The two matrices below list every target here. Two jobs with different +# roles: # # * fuzz-regression (pull_request and manual, blocking on PRs): builds every harness, which # catches harness and API drift, and replays the committed seed corpus with @@ -24,6 +26,18 @@ on: - packages/stack-auth/** - packages/stack-kms/** - packages/stack-encrypt/** + # Every other crate a target compiles, from each fuzz crate's + # Cargo.lock: scripts/__tests__/fuzz-workflow-paths.test.mjs derives + # this list and fails when a path dependency is missing from it. + - packages/stack-profile/** + - packages/stack-encrypt-derive/** + - packages/eql/crates/eql-bindings/** + # The Python SDK's core crate, the manifest its dependencies come + # from, and the Go guest's EQL resolver it compiles by #[path]: the + # fuzz:python-values target. + - languages/python/crates/core/** + - languages/python/Cargo.toml + - languages/golang/encrypt/guest/src/targets.rs - Cargo.toml - Cargo.lock - mise.toml @@ -116,6 +130,7 @@ jobs: - { task: "fuzz:plan-build", slug: plan-build } - { task: "fuzz:json-wire", slug: json-wire } - { task: "fuzz:json-text", slug: json-text } + - { task: "fuzz:python-values", slug: python-values } steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: @@ -140,6 +155,7 @@ jobs: packages/stack-auth/fuzz packages/stack-kms/fuzz packages/stack-encrypt/fuzz + languages/python/crates/core/fuzz key: ${{ matrix.slug }} # `-runs=0` replays the committed seed corpus once and exits without @@ -166,6 +182,7 @@ jobs: - { task: "fuzz:plan-build", slug: plan-build, dir: packages/stack-encrypt, target: plan_build } - { task: "fuzz:json-wire", slug: json-wire, dir: packages/stack-encrypt, target: json_wire_decode } - { task: "fuzz:json-text", slug: json-text, dir: packages/stack-encrypt, target: json_text } + - { task: "fuzz:python-values", slug: python-values, dir: languages/python/crates/core, target: value_frame } steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: @@ -205,13 +222,16 @@ jobs: run: mise run ${{ matrix.task }} -- "-max_total_time=$MAX_TOTAL_TIME" # Drops inputs that add no coverage, so the saved corpus stays small. - # Skipped when the fuzz step found a crash. + # Skipped when the fuzz step found a crash. Run from the root, naming + # the fuzz crate: from languages/python, mise would load that folder's + # mise.toml and install its Python and uv, which this job does not + # cache. - name: Minimise the corpus (${{ matrix.slug }}) - working-directory: ${{ matrix.dir }} env: TARGET: ${{ matrix.target }} + FUZZ_DIR: ${{ matrix.dir }}/fuzz run: | - cargo +nightly fuzz cmin "$TARGET" --sanitizer none --target "$(rustc -vV | sed -n 's/^host: //p')" + cargo +nightly fuzz cmin "$TARGET" --fuzz-dir "$FUZZ_DIR" --sanitizer none --target "$(rustc -vV | sed -n 's/^host: //p')" # Saved even on a crash: the grown corpus is still worth keeping, and the # crash input lives in artifacts/, not corpus/. diff --git a/.github/workflows/tests-python.yml b/.github/workflows/tests-python.yml new file mode 100644 index 0000000000..01c9ef0097 --- /dev/null +++ b/.github/workflows/tests-python.yml @@ -0,0 +1,613 @@ +name: Tests (Python) + +# The Python SDK workspace at languages/python: the crates stackencrypt-core +# and stackencrypt-engine, and the pure-Python package `stackencrypt`. It is +# the fourth Cargo workspace. No other workflow runs its checks. +# tests-crates.yml compiles the stack-* crates but never links them into +# pyo3. tests.yml's `changes` filter excludes those crates on purpose. So +# this workflow is the only one that catches a stack-encrypt change that +# breaks the engine crate. scripts/__tests__/python-ci.test.mjs derives the +# `pull_request` filter below from the workspace's Cargo.lock. A new path +# dependency fails that test until the filter follows. +# +# Two jobs. `python` runs the Rust checks and the pure-Python suite on two +# interpreters: the oldest supported one (the `requires-python` floor) and +# the one mise.toml names. A wheel tagged abi3-py39 claims to work on the +# floor. Only a run on the floor tests that claim. `wheels` builds one abi3 +# wheel per platform the FFI ships for and uploads them as artifacts. Nothing +# is released. The two jobs are independent, so a pytest failure does not +# hide whether the wheels build. +# +# A third job, `live`, runs the tests that need CipherStash credentials and a +# PostgreSQL with the EQL v4 bundle (#1389). It is the only job with a +# credential. It runs on pushes and on pull requests from this repository, +# and skips on a fork's pull request, which GitHub gives no secrets. + +on: + pull_request: + paths: + - languages/python/** + # The example has its own lock and its own tests, which CI does not + # run. A change only there must not start this workflow. + - '!languages/python/examples/**' + # The crates the workspace compiles by path, in the order Cargo.lock + # lists them. The last two are what stack-kms's `profile` feature pulls + # in. The root manifest + # comes with them. Their requirements are `{ workspace = true }`. Those + # resolve from the root Cargo.toml even when built from this workspace. + - packages/stack-encrypt/** + - packages/stack-encrypt-derive/** + - packages/stack-kms/** + - packages/stack-auth/** + - packages/stack-profile/** + - Cargo.toml + # Cargo reads the root .cargo/config.toml for this workspace too. A + # [patch] or [build] change there changes what this workspace compiles. + - .cargo/** + # The EQL codegen and the catalog it reads. The SDK's marker names are + # generated from the catalog (#1385). A change to either must rerun the + # suite that reads them. + - packages/eql/crates/eql-codegen/** + - packages/eql/crates/eql-domains/** + # The EQL types the engine check resolves (#1387). The core crate links + # eql-bindings by path, and compiles the Go guest's resolver file + # in place, so a change to either reruns this suite. + - packages/eql/crates/eql-bindings/** + - languages/golang/encrypt/guest/src/targets.rs + # The EQL rows the Go SDK stored under a deterministic key source. + # `mise run test:deterministic` opens them and seals the same values, + # so a change to them must rerun that check. + - packages/eql/tests/encryption/fixtures/** + # The Rust version the workspace builds under. The workspace's own + # mise.toml is under languages/python/** above. + - mise.toml + # The `live` job installs the EQL v4 bundle built from this SQL, as the + # Go workflow's eql-postgres job does, and runs the secrets pre-flight. + - packages/eql/src/** + - packages/eql/mise.toml + # The EQL build compiles eql-codegen, whose versions these two choose. + - packages/eql/Cargo.toml + - packages/eql/Cargo.lock + - packages/eql/tasks/** + - .github/actions/require-cs-secrets/** + - .github/workflows/tests-python.yml + # Unfiltered, like tests-rust.yml. Every push to main runs the suite, so + # main runs a superset of what the filtered `pull_request` trigger above + # runs. Recorded in scripts/__tests__/workflow-paths-filter-parity.test.mjs. + push: + branches: [main] + workflow_dispatch: {} + +permissions: + contents: read + +defaults: + run: + shell: bash + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + RUST_BACKTRACE: full + CARGO_TERM_COLOR: always + CARGO_NET_GIT_FETCH_WITH_CLI: true + +jobs: + python: + name: "Python ${{ matrix.python }}: fmt, clippy, cargo test, pytest" + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 45 + strategy: + # Both interpreters report. A failure on 3.9 alone means the abi3 floor + # is broken. That reads differently from a failure on both. + fail-fast: false + matrix: + # Quoted on purpose: YAML reads a bare 3.10 as the number 3.1. + python: ["3.9", "3.14"] + env: + RUSTFLAGS: "-D warnings" + # languages/python/mise.toml names python = "3.14" for the local loop. + # mise reads MISE__VERSION before any config file. So this one + # variable moves every step to the leg's interpreter, the mise-action + # install included. `mise run build` and `test:cargo` take `python3` + # from PATH, so they follow it. The step "Check that python3 is the + # leg's interpreter" below fails the leg when that did not happen. + MISE_PYTHON_VERSION: ${{ matrix.python }} + defaults: + run: + working-directory: languages/python + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + # Run in the workspace folder, so mise merges languages/python/mise.toml + # (python, uv, the tasks) over the root one (rust 1.94.1). Only the + # three tools this job uses are installed. With no install_args, + # `install: true` also installs the root's Go and golangci-lint, and + # nothing here runs them. The cache key hashes the config, not the + # environment, so both legs share it. The leg that saves first holds + # its interpreter. The other leg downloads its own, uncached. That + # costs a download and nothing else: see the interpreter check below. + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + install_args: python uv rust + working_directory: languages/python + cache: true + # Must name runs-on's image; see scripts/__tests__/workflow-mise-cache-image.test.mjs + cache_key_prefix: mise-v1-ubuntu-2404 + + # mise caches its rust install as a symlink into ~/.rustup. The cache + # does not hold ~/.rustup, so on a cache hit the components mise.toml + # declares can be missing. The command does nothing when they are + # there. tests-crates.yml has the same step for the same reason. + - name: Add rustfmt and clippy to the toolchain mise installed + run: rustup component add rustfmt clippy + + # The matrix tests nothing unless the interpreter on PATH is the leg's. + # If mise stopped reading the version from the environment, both legs + # would run on 3.14 and both would pass. + - name: Check that python3 is the leg's interpreter + env: + WANT: ${{ matrix.python }} + run: | + set -euo pipefail + have="$(mise x -- python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])')" + if [ "$have" != "$WANT" ]; then + echo "::error::python3 on PATH is $have, this leg wants $WANT: MISE_PYTHON_VERSION was not honoured" + exit 1 + fi + echo "python3 is $have" + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: languages/python + + - name: rustfmt + run: cargo fmt --all --check + + # pyo3's build script asks the `python3` on PATH for its configuration. + # That is why this runs after mise is installed, once per interpreter. + # With every feature, so the code behind the test features + # (deterministic-kms, gate-probe) is linted too. + - name: clippy + run: cargo clippy --locked --no-deps --workspace --all-targets --all-features -- -D warnings + + # `cargo test --locked` for both crates. The task puts the interpreter's + # library directory on the loader path, because the engine crate links + # libpython outside a wheel. + - name: cargo test + run: mise run test:cargo + + # Builds the extension with maturin (dev profile) into a virtualenv for + # the leg's interpreter, then runs pytest in it. + - name: pytest + run: mise run test + + # Rebuilds the extension with the deterministic-kms test feature, then + # runs tests_deterministic/: Python opens the EQL rows the Go SDK stored + # under a deterministic key source, the frozen ones among them, and + # seals the same values to the same terms. No credentials. It runs after + # `pytest` because it replaces the build in .venv with a test build. + - name: Python against the Go SDK's rows + run: mise run test:deterministic + + # The committed stackencrypt/_eql.py is generated from the EQL catalog + # by eql-codegen (#1385). `mise run types:check` in test-eql.yml diffs + # it, but that workflow does not run on a change under languages/python, + # so a hand edit to the file reached no check. This runs the one test + # that renders the catalog and compares it with the committed file, + # plus the Go/Python attribute parity in the same file. It compiles + # eql-codegen and its catalog only, in the EQL workspace, under the + # root Rust toolchain; tests-golang.yml compiles that workspace the + # same way. The target directory is not cached: the build is seconds. + - name: stackencrypt._eql matches the EQL catalog + run: cargo test --locked --manifest-path ../../packages/eql/Cargo.toml -p eql-codegen --test py_eql_parity + + # `encrypt` against real ZeroKMS, and the row it returns inserted into a + # PostgreSQL with the EQL v4 bundle built from the tree. The live tests skip + # without credentials or a database, so the pre-flight below turns a missing + # secret into a failure, and `mise run test:live` fails on any skip. + live: + name: "Python live: encrypt into PostgreSQL with EQL v4" + # A fork's pull request has no secrets: skip it rather than fail it, as + # the integration workflows do. Dependabot's pull requests come from this + # repository but get no Actions secrets either, so they skip too. + if: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor != 'dependabot[bot]' }} + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 45 + # The CS_* credentials are given to the `Live tests` step alone, not the + # job. The other steps run third-party code: each dependency's build.rs + # and proc-macros, and maturin. That code no longer runs with the + # credentials set. It still shares this runner with the test step, so a + # file a build step leaves behind, or the compiled extension itself, runs + # with them later. Building the wheel in a job with no secrets, and + # installing only that wheel here, would close that gap. + services: + postgres: + image: postgres:17 + env: + POSTGRES_PASSWORD: postgres + # Container port only: Docker picks the host port, and the test step + # reads it from `job.services`. + ports: + - 5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + defaults: + run: + working-directory: languages/python + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + # Fast pre-flight: fail in seconds if a secret was rotated or cleared. + # Ordering is asserted by scripts/__tests__/ffi-binding-step-order.test.mjs. + - uses: ./.github/actions/require-cs-secrets + with: + workspace-crn: ${{ vars.CS_WORKSPACE_CRN }} + client-id: ${{ vars.CS_CLIENT_ID }} + client-key: ${{ secrets.CS_CLIENT_KEY }} + client-access-key: ${{ secrets.CS_CLIENT_ACCESS_KEY }} + + # This workspace's tools (python, uv, rust), as the `python` job. + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + install_args: python uv rust + working_directory: languages/python + cache: true + # Must name runs-on's image; see scripts/__tests__/workflow-mise-cache-image.test.mjs + cache_key_prefix: mise-v1-ubuntu-2404 + + # EQL's tools, for the build of its bundle. EQL's mise.toml names its + # own Rust. + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: packages/eql + cache: true + # Must name runs-on's image; see scripts/__tests__/workflow-mise-cache-image.test.mjs + cache_key_prefix: mise-v1-ubuntu-2404 + + # One step for both workspaces: this one, and EQL's, whose eql-codegen + # writes the bundle. Two steps would each prune the shared cargo + # registry to their own crates before they save, so each save would + # lose the other workspace's crates. + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: | + languages/python + packages/eql + + # The builds, with no credentials: the extension into .venv, and the + # EQL v4 bundle. + - name: Build the extension and the EQL bundle + run: | + mise run build + mise run build:eql-bundle + + # Installs the bundle and runs the live tests, building nothing; a skip + # fails the task. The only step with the credentials. + - name: Live tests + env: + CS_WORKSPACE_CRN: ${{ vars.CS_WORKSPACE_CRN }} + CS_CLIENT_ID: ${{ vars.CS_CLIENT_ID }} + CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }} + CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }} + EQL_TEST_DATABASE_URL: "host=localhost port=${{ job.services.postgres.ports['5432'] }} user=postgres password=postgres dbname=postgres sslmode=disable" + run: mise run test:live:run + + # One abi3-py39 wheel per platform the FFI ships for, by the same Rust + # triples. python-ci.test.mjs holds the list to the protect-ffi platform + # packages. The wheels are uploaded as artifacts. Nothing publishes them: + # the spec puts PyPI out of scope. Each build time is written to the leg's + # own job summary and, as one table, to `wheels-summary`'s. + # + # Release profile, on purpose. The local loop builds the dev profile for + # speed (languages/python/mise.toml). The number this job records is the + # cost of the wheel a user would install. + wheels: + name: "wheel ${{ matrix.platform }}" + strategy: + # One platform failing must not cancel the others. The failure is + # usually specific to a toolchain. Seeing which platforms pass is most + # of the diagnosis. + fail-fast: false + matrix: + # `platform` is the FFI's name for the leg and `target` its triple, + # read from languages/typescript/packages/protect-ffi/platforms/*/package.json. + # `wheel` is the platform tag the wheel's filename must carry. It is + # the one fact each leg checks about what it built. `native` marks + # the legs whose runner can import the wheel it built. + # + # Linux builds run inside maturin-action's manylinux containers. That + # gives the gnu wheels their glibc floor, 2.28. The FFI's zigbuild + # passes the same floor as `--target .2.28`. The musl wheel is + # built in the musllinux_1_2 container, a musl system. The FFI's musl + # binary is built in Alpine for the same reason. Both Darwin legs + # share macos-latest, an arm64 runner. So `target` is what makes + # darwin-x64 an x86_64 wheel, not a second arm64 wheel with the wrong + # name. + include: + - platform: linux-x64-gnu + target: x86_64-unknown-linux-gnu + os: blacksmith-4vcpu-ubuntu-2404 + manylinux: "2_28" + wheel: manylinux_2_28_x86_64 + native: true + - platform: linux-arm64-gnu + target: aarch64-unknown-linux-gnu + os: blacksmith-4vcpu-ubuntu-2404 + manylinux: "2_28" + wheel: manylinux_2_28_aarch64 + native: false + - platform: linux-x64-musl + target: x86_64-unknown-linux-musl + os: blacksmith-4vcpu-ubuntu-2404 + manylinux: musllinux_1_2 + wheel: musllinux_1_2_x86_64 + native: false + - platform: darwin-x64 + target: x86_64-apple-darwin + os: macos-latest + manylinux: auto + wheel: macosx_[0-9_]*_x86_64 + native: false + - platform: darwin-arm64 + target: aarch64-apple-darwin + os: macos-latest + manylinux: auto + wheel: macosx_[0-9_]*_arm64 + native: true + - platform: win32-x64-msvc + target: x86_64-pc-windows-msvc + os: windows-latest + manylinux: auto + wheel: win_amd64 + native: true + runs-on: ${{ matrix.os }} + # A release build of the stack-encrypt graph, cross-compiled on two legs, + # on a 4 vCPU runner. Both green runs of 2026-10-28 took 1 to 5 minutes + # per leg. A cold cache adds a registry fetch and a full compile, so this + # leaves room for one. + timeout-minutes: 30 + steps: + # Git for Windows defaults to CRLF on checkout. `cargo fmt` does not + # run here, but maturin would read a CRLF pyproject.toml. + - name: Keep LF line endings + run: git config --global core.autocrlf false + + # The pull request's head commit, not GitHub's test merge commit: the + # wheel records the head commit as its engine commit, so it must be + # built from that commit's files. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + # One source of truth for the Rust version: the root mise.toml the + # `python` job builds under. mise itself is not installed here. The + # Linux legs compile inside a container, where maturin-action installs + # the toolchain this step names. If that line changes shape, this step + # fails by name. Otherwise maturin-action would receive an empty + # toolchain and read it as "stable". + - name: Rust version from mise.toml + id: rust + run: | + version=$(sed -n 's/^rust = { version = "\([^"]*\)".*/\1/p' mise.toml) + if [ -z "$version" ]; then + echo 'no rust = { version = "..." } line in mise.toml; the Rust version line has changed shape' >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + # The floor, not the newest. An abi3-py39 wheel claims to load on 3.9, + # and the import below tests that claim. maturin needs no interpreter + # for an abi3 build, so this is for the import alone. "3.9" resolves to + # the newest 3.9.x the runner has a build for. That is 3.9.13 on arm64 + # macOS and on Windows, where the later security releases shipped no + # binaries (actions/python-versions manifest, 2026-10-27). It is 3.9.25 + # on Linux and x64 macOS. + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.9" + + # The toolchain the wheel is built with. It is installed before the + # cache step so that step keys on it. rust-cache reads `rustc -vV` from + # the default toolchain. Without this step it would key on whatever the + # runner image ships and never follow the version mise.toml names. + # maturin-action installs the same version again inside the Linux + # containers, where this install is not visible. `rustup toolchain + # install` does nothing where the version is already installed. + - name: Install the Rust toolchain mise.toml names + env: + RUST_VERSION: ${{ steps.rust.outputs.version }} + run: | + set -euo pipefail + rustup toolchain install "$RUST_VERSION" --profile minimal + rustup default "$RUST_VERSION" + + # Keyed by target as well as by lockfile, so the legs do not evict each + # other. On the three host legs this caches the registry and the + # workspace `target/` directory. On the three container legs it caches + # little. The container keeps its own registry, and it writes `target/` + # as root through the bind mount, so the post step may only warn. It + # stays on every leg because every leg compiles Rust (#1383). + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: languages/python + key: ${{ matrix.target }} + + # The build time below is measured from here, so it includes everything + # maturin-action does. On the three Linux legs that is the container + # pull and the toolchain install inside it. On every leg it is the + # registry fetch on a cache miss, and the compile itself. + - name: Record the start time + id: clock + run: echo "start=$(date +%s)" >> "$GITHUB_OUTPUT" + + # The commit `stackencrypt.__engine__` reports. The core crate's build + # script reads it from git. The three Linux legs build in a container, + # as root, on a bind mount the runner user owns. There git refused to + # answer. The first run failed on all three with "no engine commit". + # So the host reads the commit and hands it over in the variable the + # build script accepts instead of git. All six legs get it, so every + # wheel names its engine the same way. The checkout is clean here, so + # the `-dirty` check the script skips with the override has nothing to + # add. python-ci.test.mjs holds the variable name to build.rs. + # On a pull request, HEAD is GitHub's test merge commit, which is on no + # branch. The pull request's head commit is the one a bug report can + # name, so the wheel records that one. + - name: Read the engine commit + id: engine + env: + COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + run: echo "commit=$COMMIT" >> "$GITHUB_OUTPUT" + + # `--locked`: the Cargo.lock in the tree is the one built, as in every + # other check of this workspace. `--out dist` puts the wheel where the + # steps below and the artifact upload look. maturin-action forwards + # only variables on its own prefix list (CARGO_, RUST, PYO3_, and so + # on) into the container, so `docker-options` names this one. + - name: Build the wheel + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 + env: + STACKENCRYPT_ENGINE_COMMIT: ${{ steps.engine.outputs.commit }} + with: + working-directory: languages/python + command: build + args: --release --locked --out dist + target: ${{ matrix.target }} + manylinux: ${{ matrix.manylinux }} + rust-toolchain: ${{ steps.rust.outputs.version }} + # A fixed maturin, inside pyproject.toml's range. Without it the + # action downloads the newest release on every run. + maturin-version: v1.16.0 + docker-options: -e STACKENCRYPT_ENGINE_COMMIT + + # The build time is the number this job exists to record. The spec's + # scorecard asks for it per platform. It is written here, to this leg's + # summary, and to a file the `wheels-summary` job gathers into one table. + - name: Record the build time + env: + PLATFORM: ${{ matrix.platform }} + START: ${{ steps.clock.outputs.start }} + run: | + set -euo pipefail + seconds=$(( $(date +%s) - START )) + mkdir -p languages/python/timing + echo "$seconds" > "languages/python/timing/${PLATFORM}.seconds" + echo "Built the ${PLATFORM} wheel in ${seconds}s" >> "$GITHUB_STEP_SUMMARY" + + # Exactly one wheel, tagged for the floor (cp39-abi3) and for this leg's + # platform. A wrong `target` on the shared Darwin runner shows up in the + # filename. So does a container that produced a manylinux tag below the + # floor. This job reads the filename and nothing else about the wheel. + - name: Check that the wheel is one, abi3-py39, for this platform + env: + PLATFORM: ${{ matrix.platform }} + WHEEL_TAG: ${{ matrix.wheel }} + run: | + set -euo pipefail + shopt -s nullglob + wheels=(languages/python/dist/*.whl) + if [ "${#wheels[@]}" -ne 1 ]; then + echo "::error::expected one wheel in languages/python/dist, found ${#wheels[@]}: ${wheels[*]}" + exit 1 + fi + name=$(basename "${wheels[0]}") + echo "$name" + if ! [[ "$name" =~ ^stackencrypt-[^-]+-cp39-abi3-${WHEEL_TAG}\.whl$ ]]; then + echo "::error::${name} is not a stackencrypt cp39-abi3 wheel tagged ${WHEEL_TAG} (the ${PLATFORM} leg)" + exit 1 + fi + + # On the legs whose runner is the wheel's platform, install it into the + # floor interpreter and load both halves. The cross-compiled legs + # (linux-arm64-gnu, linux-x64-musl, darwin-x64) cannot run theirs here. + # For them the filename check above is the only check. + - name: Import the wheel on Python 3.9 + if: ${{ matrix.native }} + run: | + set -euo pipefail + python -m pip install --no-index --no-deps languages/python/dist/*.whl + python -c 'import stackencrypt as s; print("stackencrypt", s.__version__, "engine", s.__engine__)' + # A wheel must not carry the deterministic key source. + python -c 'from stackencrypt import _engine; assert not hasattr(_engine, "_use_deterministic_keys"), "this wheel has the deterministic-kms test feature"' + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: wheel-${{ matrix.platform }} + path: languages/python/dist/*.whl + if-no-files-found: error + retention-days: 14 + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: wheel-timing-${{ matrix.platform }} + path: languages/python/timing/*.seconds + if-no-files-found: error + retention-days: 14 + + # All the wheels in one place. The job counts them against the platform + # list and writes one table of build time per platform to the job summary. + # A missing wheel fails here by platform name. So a leg that uploaded + # nothing is not mistaken for a leg whose job was skipped. + wheels-summary: + name: one wheel per platform, build time per platform + needs: [wheels] + # Run when a leg fails too, so the table still shows the legs that built. + if: ${{ !cancelled() }} + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 10 + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: wheel-* + merge-multiple: true + path: wheels + + - name: One wheel per platform, and their build times + run: | + set -euo pipefail + shopt -s nullglob + # A copy of the wheel matrix's `platform` values. This job cannot + # read the matrix. python-ci.test.mjs holds the copy to the + # original. The expected count comes from this list alone. + platforms="linux-x64-gnu linux-arm64-gnu linux-x64-musl darwin-x64 darwin-arm64 win32-x64-msvc" + read -ra platform_list <<<"$platforms" + want=${#platform_list[@]} + wheels=(wheels/*.whl) + { + echo "## Python wheels" + echo "" + echo "| platform | wheel | build time |" + echo "|---|---|---:|" + } >> "$GITHUB_STEP_SUMMARY" + missing=0 + for platform in "${platform_list[@]}"; do + timing="wheels/${platform}.seconds" + if [ ! -f "$timing" ]; then + echo "::error::no build time recorded for ${platform}" + missing=1 + continue + fi + echo "| ${platform} | see artifact wheel-${platform} | $(cat "$timing")s |" >> "$GITHUB_STEP_SUMMARY" + done + echo "" >> "$GITHUB_STEP_SUMMARY" + printf '%s\n' "${wheels[@]##*/}" | sed 's/^/- /' >> "$GITHUB_STEP_SUMMARY" + if [ "${#wheels[@]}" -ne "$want" ] || [ "$missing" -ne 0 ]; then + echo "::error::expected ${want} wheels with ${want} build times, found ${#wheels[@]} wheels: ${wheels[*]##*/}" + exit 1 + fi diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7939d29e4a..564e738aab 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -96,10 +96,12 @@ jobs: # Plans and ADRs; plan-docs-unread-by-tests.test.mjs holds the # plans half for the `paths:` filter above. - '!docs/**' - # The Go module, and the Rust crates no binding these jobs build + # The Go module, the Python SDK (tests-python.yml runs its + # checks), and the Rust crates no binding these jobs build # depends on. The auth and profile bindings compile stack-auth and # stack-profile, which stay in. - '!languages/golang/**' + - '!languages/python/**' - '!packages/stack-encrypt/**' - '!packages/stack-encrypt-derive/**' - '!packages/stack-kms/**' diff --git a/.gitignore b/.gitignore index 268d67b85b..6c04d5b00a 100644 --- a/.gitignore +++ b/.gitignore @@ -107,6 +107,10 @@ packages/*/fuzz/artifacts/ packages/*/fuzz/coverage/ packages/*/fuzz/corpus/*/* !packages/*/fuzz/corpus/*/valid-* +languages/python/crates/core/fuzz/artifacts/ +languages/python/crates/core/fuzz/coverage/ +languages/python/crates/core/fuzz/corpus/*/* +!languages/python/crates/core/fuzz/corpus/*/valid-* # cargo-mutants output (`mise run mutants:` writes it to the cwd). mutants.out/ diff --git a/AGENTS.md b/AGENTS.md index 20a056d9e9..14152435fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,15 +93,17 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l - `packages/stack-auth`, `packages/stack-profile`, `packages/stack-kms`, `packages/stack-encrypt`, `packages/stack-encrypt-derive`, `packages/stack-guest-abi`: The Rust crates imported from `cipherstash/cipherstash-suite` with their history — `stack-auth` and `stack-profile` (published to crates.io, one version group), `stack-kms` (published to crates.io from 0.1.0, its own version group, re-exported by `stack-encrypt` as `stack_encrypt::kms`), `stack-encrypt` and `stack-encrypt-derive` (published to crates.io from 0.1.0, one version group; `eql-bindings`' `stack-encrypt` feature depends on them from the registry), and `stack-guest-abi` (`publish = false`). They are the members of the **root Cargo workspace**, with the three node binding crates below. See "Working on the Rust crates". - `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm from this repository by `release.yml` (`auth-artifacts`, `publish-auth`); a change to what it ships, the `stack-auth` crate included, needs an `@cipherstash/auth` changeset (`require-auth-npm-changeset.yml`). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. - `languages/golang`: The Go module — the SDK `encrypt` with its generated-code support `encrypt/gensupport` and the policy packages `encrypt/policy` and `encrypt/policy/protosource`; the credential package `auth`; the generator `stashgen` and its command `cmd/stashgen`; and `internal` (the shared guest plumbing and the `record` wire model). A wazero host with no cgo. Its two WASI guests (`encrypt/guest`, `auth/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; `mise run wasm:guest:build:deterministic` builds the seeded TEST build the hermetic Go tests use; the `.wasm` files they embed are gitignored. The stack-encrypt guest has a second build with the EQL types (`mise run wasm:guest:build:eql`, cargo feature `eql`, linking `eql-bindings` by path; `wasm:guest:build:eql:deterministic` is its test build), embedded by `encrypt/eql` and registered on import, so a program whose generated code names an EQL type runs it (ADR-0007, amended). `encrypt/eql`'s types and `Types` table are generated from the EQL catalog by `eql-codegen` (`eql_gen.go`, written by `mise run types:generate` in `packages/eql` and drift-gated by `types:check` and `cargo test -p eql-codegen`). Generated `*_stash.go` files are committed and CI fails when `go generate ./...` changes one. Releases are cut by hand with `.github/workflows/release-golang.yml` (`gh workflow run release-golang.yml -f version=X.Y.Z[-pre] --ref `): because the guests are gitignored on main and Go fetches a version as the files committed at its tag, the workflow builds them, commits them on a release-only commit no branch points at, tags it `languages/golang/vX.Y.Z` and pushes the tag alone, then attaches the EQL v4 SQL bundle to the GitHub release. The ref must carry the EQL v4 bundle, or the build stops at "Build the EQL SQL bundles". `scripts/go-release-version.mjs` refuses, before anything is built, every version Go would refuse (non-canonical semver, `+build` metadata, a v2+ major on a module path with no `/vN` suffix, a version shaped like a pseudo-version), a final version dispatched from anywhere but `main`, and a final version while `languages/golang/go.mod` pins a `github.com/cipherstash/vitaminc/…` module at a pseudo-version (vitaminc's Go bindings have no tags, and a TEMPORARY pin on an unreleased commit must be gone before the module is tagged); a pre-release may come from a branch and may carry such a pin. The build job also refuses a version the Go proxy already lists in `@v/list`, which catches a tag deleted after the proxy fetched it; it never asks the proxy for the version itself before the tag exists, because the proxy would cache "not found" for up to 30 minutes and `verify` would fail after the push. The build checks the `Cargo.lock` of the EQL workspace and both guest workspaces with `--locked` first, since none of those builds passes it. It runs in a job with no write token and hands the guests to a `publish` job that holds `contents: write` and runs only git and gh. `publish` fetches the commit with plain unauthenticated git rather than `actions/checkout`, whose `token` input defaults to the job's writable token, and refuses a release commit whose tree is not the one the build tested. `publish` takes the paths it commits from the workflow's fixed `GUESTS` list, never from the artifact, and refuses a `guests.sha256` that is not exactly one SHA-256 line for each of those three guests: the build job runs third-party code after writing that manifest, so a path read from it could put any file on the tag (`scripts/__tests__/go-release-artifact.test.mjs`). A version is immutable once the Go proxy has fetched it, so a tag is never moved or deleted: if `publish` or `verify` fails after the tag is pushed, use "Re-run failed jobs" on the same run, which keeps a tag that run pushed and creates or completes the release; a new dispatch of the same version is refused. Never tag a main commit by hand: `go get` would fetch a module with no guests. +- `languages/python`: The Python SDK, `stackencrypt`, a pyo3 extension over `stack-encrypt`, built by maturin. **The fourth Cargo workspace**, excluded from the root one. It has two crates. `crates/core` has no pyo3: the engine-side logic, linked and tested without a Python interpreter. `crates/engine` is the `_engine` cdylib, `abi3-py39`, one wheel per platform for Python 3.9 to 3.14. `crates/core/fuzz` is a detached cargo-fuzz crate over the value conversion. The root `mise.toml`'s `fuzz:python-values` task runs it, from `fuzz.yml`, so it is the one Python task outside `languages/python/mise.toml`. `crates/core/testdata/value-conformance.json` is a copy of vitaminc's shared value corpus; its `README.md` records the commit it came from. `crates/core` compiles the Go guest's EQL resolver, `languages/golang/encrypt/guest/src/targets.rs`, in place through `#[path]`. So both SDKs run one resolver, and a change to that file is a change to both. The `extension-module` feature is set only by maturin, never in a crate manifest, so `cargo test` links libpython. `pyproject.toml` sets it, and `test:deterministic` names it again in its `--features` list; `scripts/__tests__/python-ci.test.mjs` holds the two lists together. Its tasks live in its own `mise.toml` and run from that folder. `mise run build` builds the wheel into `.venv`. `mise run test` runs pytest. `mise run test:cargo` runs plain `cargo test --locked` for both crates, with libpython on the loader path. It also runs one test binary of `crates/core`, `deterministic_switch`, with the `deterministic-kms` feature: the switch is for the whole process, so that test needs a process of its own. Its extension tests include `crates/engine/src/heap_hygiene.rs`: a spy allocator fails the test when a block the extension frees during `encrypt`, `query` or `decrypt` still contains text or decimal plaintext. Python's own objects are out of its scope. It runs on Unix, and CI runs it on Linux. `mise run test:deterministic` rebuilds the extension with the `deterministic-kms` test feature, whose keys come from a seed through stack-kms's `DeterministicSource`. It then runs `tests_deterministic/` in its own pytest process: Python opens the EQL rows the Go SDK stored under that seed in `packages/eql/tests/encryption/fixtures/`, the frozen `stored_v4_rows_v1.json` among them, and seals the same values to the same terms. It does not use nextest, and its CI job sets no `MISE_ENV`. The nextest rule under "Working on the Rust crates" is for the root workspace, whose `stack-kms` tests set environment variables. No test in this workspace does, and nextest comes from `mise.test.toml`, which this workspace's CI does not load. The wheel's version is `crates/engine/Cargo.toml`'s. `stackencrypt.__engine__` is the commit of this checkout, recorded by `crates/core/build.rs`. Its `Cargo.lock` is seeded from the root one, so the crates that decide what a stored value is resolve to the versions the root tests: every `vitaminc` crate, `recipher`, `ore-rs`, `clww-ore`, `cts-common` and `zerokms-protocol`. `scripts/__tests__/python-lock-parity.test.mjs` compares those crates in the two locks, by version, source and checksum. Other shared crates may differ. To re-seed, copy the root `Cargo.lock` over it and run `cargo metadata` there. That command prunes the copy to what the workspace uses. It can also move a crate to a newer version, when this workspace turns on a feature the root does not. Move each such crate back with `cargo update -p @ --precise `, until the parity test passes. Nothing is published. CI is `tests-python.yml`. It runs the Rust checks and pytest on Python 3.9 and 3.14, where `MISE_PYTHON_VERSION` moves a leg off the local version. A `live` job runs the live tests: `encrypt`, `decrypt` and the query methods against ZeroKMS with the repository's `CS_*` secrets, and the rows inserted into, searched in and read back from a `postgres:17` service with the EQL v4 bundle built from the tree. It builds the extension and the bundle in a step with no credentials, then runs `mise run test:live:run`, which builds nothing, in the only step that has them. So no dependency's build script runs with the credentials set. The two steps share one runner, though: a file a build step leaves behind, or the extension itself, still runs with them. Building the wheel in a separate job with no secrets would close that gap. Locally, `mise run test:live` does both. It skips on a fork's pull request, and the task fails on any skipped test. A third job, `wheels`, builds one abi3 wheel per FFI platform, uploads them as artifacts, and writes each build time to the job summary. `scripts/__tests__/python-ci.test.mjs` compares its path filter with the workspace's `Cargo.lock` and its wheel matrix with the protect-ffi platform packages. `languages/python/uv.lock` locks the `test` extra: pytest, attrs, Pydantic, SQLAlchemy, psycopg, and Alembic. It also locks maturin, the build backend, in the `build` dependency group. The package has no runtime dependency, so nothing else is locked. attrs, Pydantic, and SQLAlchemy are for the declaration tests, and SQLAlchemy also for `stackencrypt.sqlalchemy`. psycopg is for the live tests. Alembic is for the tests of the migrations that `stackencrypt.sqlalchemy`'s two Alembic hooks write. `mise run build` installs them with `uv sync --locked`, so a lock that does not match `pyproject.toml` fails the build. It then builds the package with `--no-build-isolation`, through the locked maturin in `.venv`, so uv downloads no build tool outside the lock. After a change to the `test` extra, run `uv lock` in `languages/python`. None has an upper bound. Dependabot's `uv` entry proposes each new minor or patch release as a lock update. The `wheels` job builds with `PyO3/maturin-action` instead, with maturin's version fixed in the workflow. `python/stackencrypt/_eql.py`, the EQL type markers, is generated from the EQL catalog by `eql-codegen py-eql`. To regenerate it, run `mise run types:generate` in `packages/eql`. Never edit it by hand: `tests-python.yml` runs its parity tests and fails on a hand edit. Design: the Python ADRs under `languages/python/docs/adr/` (ADR-0001, the declaration; ADR-0002, a query names its field by a string; ADR-0003, one method for each query operator; ADR-0004, the SQLAlchemy layer encrypts in batch hooks), the spec in #1341. `languages/python/docs/scorecard.md` marks the eight shared principles of `docs/sdk-design-principles.md` against the code. The README's SQL and Python blocks run in order in the `live` job, through `tests/test_readme_live.py`, so a change to a README step needs that test to pass. - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) - `languages/typescript/examples/*`: Working apps (basic, prisma, supabase-worker) +- `languages/python/examples/customer-directory`: An internal FastAPI example of the Python SDK, written twice: with psycopg (`app.py`) and with SQLAlchemy (`app_sqlalchemy.py`). It has its own `pyproject.toml` and `uv.lock`, and installs `stackencrypt` from this checkout. Its tests need real credentials, and CI does not run them. - `docs/plans/*`: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo). - `skills/*`: Agent skills (`stash-cli`, `stash-encryption`, `stash-indexing`, `stash-deployment`, `stash-zerokms`, `stash-auth`, `stash-postgres`, `stash-edge`, `stash-drizzle`, `stash-dynamodb`, `stash-supabase`, `stash-prisma`, `stash-managed-platforms`, `stash-supply-chain-security`) ## Working on protect-ffi -`languages/typescript/packages/protect-ffi` carries one of this repo's three Cargo workspaces (the -others are the root workspace, for the stack-* crates, and `packages/eql`), and its scripts are split so a Rust toolchain +`languages/typescript/packages/protect-ffi` carries one of this repo's four Cargo workspaces (the +others are the root workspace, for the stack-* crates, `packages/eql` and `languages/python`), and its scripts are split so a Rust toolchain stays optional for everyone else. - **The default `test` and `build` never invoke cargo.** Root `pnpm test` runs @@ -715,10 +717,12 @@ term. `cts-common` and `zerokms-protocol` take caret requirements, because the published `stack-auth` inherits them, and an exact pin would stop the suite sharing one `cts-common` with it. -- **Three Cargo workspaces, not one.** The root workspace (the six stack-* - crates and the three node binding crates), protect-ffi's and EQL's. The root - `Cargo.toml` excludes the other two, and they pin their own vitaminc. The - three fuzz crates, the two Go guests and the EQL v4 block ORE timing +- **Four Cargo workspaces, not one.** The root workspace (the six stack-* + crates and the three node binding crates), protect-ffi's, EQL's and the + Python SDK's (`languages/python`). The root `Cargo.toml` excludes the other + three; protect-ffi and EQL set their own vitaminc version. The + four fuzz crates (three under `packages/*/fuzz`, one under + `languages/python/crates/core/fuzz`), the two Go guests and the EQL v4 block ORE timing harness's ciphertext generator (`packages/eql/tests/timing/ore_block_v2/ctgen`) are detached workspaces too, each with its own `Cargo.lock`. None of them has a Dependabot entry. @@ -748,14 +752,17 @@ exact pin would stop the suite sharing one `cts-common` with it. typings to the committed `native.d.ts`; `index.d.ts` is hand-written. After a `#[napi]` change, rebuild and commit `native.d.ts`, or CI's drift guard fails. -- **CI:** `tests-crates.yml` (fmt, clippy, nextest including the trybuild - `tests/ui` binary, doctests, rustdoc, the node bindings and - `stack-auth-wasm`), `tests-golang.yml` (WASI checks, the guests, the Go - module on Linux, macOS and Windows, its PostgreSQL tests against the EQL v4 - bundle built from the tree — `mise run go:test:postgres` locally, which also - runs the Rust and Go EQL/JSON conformance lane and so needs `psql` and the - `MISE_ENV=test` cargo tools — and the live tests), `crap-crates.yml`, - `mutants.yml`, `fuzz.yml`, `miri.yml` and `udeps.yml`. +- **CI:** `tests-crates.yml` runs fmt, clippy, nextest including the trybuild + `tests/ui` binary, doctests, rustdoc, the node bindings, and + `stack-auth-wasm`. `tests-golang.yml` runs the WASI checks, the guests, the + Go module on Linux, macOS, and Windows, and the live tests. It also runs the + Go PostgreSQL tests against the EQL v4 bundle built from the tree. Locally + that is `mise run go:test:postgres`, which also runs the Rust and Go + EQL/JSON conformance lane and so needs `psql` and the `MISE_ENV=test` + cargo tools. `tests-python.yml` runs the Python + SDK's two crates and pytest on both interpreters, and builds the six + wheels. The others are `crap-crates.yml`, `mutants.yml`, `fuzz.yml`, + `miri.yml`, and `udeps.yml`. `scripts/__tests__/crates-ci.test.mjs` fails when a mise task in the root `mise.toml` or the five crate `tasks.toml` files loses its last CI caller. - **Auth refresh cancellation soak:** `tests-auth-refresh.yml` runs a short, @@ -823,8 +830,9 @@ mutation in the same function stays covered. Read [`docs/sdk-design-principles.md`](docs/sdk-design-principles.md) before you architect, design or build a language SDK, or change the Go module's public -surface. It holds eight principles for every language SDK and thirteen for the -Go SDK, and the order to apply them in when two disagree. +surface. It holds eight principles for every language SDK, thirteen for the +Go SDK, five started for the Python SDK, and the order to apply them in when +two disagree. `packages/stack-encrypt/docs/adr/0008-language-sdk-design-principles.md` records why. Two terms from it are used across this repository: diff --git a/Cargo.toml b/Cargo.toml index 7dec619632..477684f3a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,9 +2,9 @@ resolver = "2" # The stack-* crates imported from cipherstash-suite, and the three node -# bindings that wrap them. The root is one of three Cargo workspaces in this -# repository: EQL and protect-ffi keep their own, and pin their own vitaminc -# (see `exclude`). +# bindings that wrap them. The root is one of four Cargo workspaces in this +# repository: EQL, protect-ffi and the Python SDK keep their own; the first +# two pin their own vitaminc (see `exclude`). members = [ "packages/stack-auth", "packages/stack-profile", @@ -29,6 +29,9 @@ exclude = [ # `wasm:auth-guest:build`. "languages/golang/encrypt/guest", "languages/golang/auth/guest", + # The Python SDK: a pyo3 extension and its core crate, built by maturin + # through `mise run build` in that folder. + "languages/python", ] [workspace.package] diff --git a/SECURITY.md b/SECURITY.md index 8c44a1d228..bdf6ea8850 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,6 +38,10 @@ from the stack-* crates), released by `.github/workflows/release-golang.yml` as `languages/golang/vX.Y.Z` tags. All of these are in scope for security reports on the same terms as the npm packages above. +The Python SDK at `languages/python` (`stackencrypt`, a pyo3 extension over +`stack-encrypt`) is in the tree and is not released: no wheel is published +and nothing in `.github/workflows` builds one for distribution. + The five stack-* crates are licensed under the PolyForm Internal Use License 1.0.0 (each crate's `LICENSE`), not the repository's MIT licence: they may be used for internal business operations only, and not redistributed. That @@ -147,7 +151,7 @@ The following are **in scope**: The following are **out of scope**: -- Example applications in the `languages/typescript/examples` dir (though we are still grateful for any relevant disclosures there) +- Example applications in the `languages/typescript/examples` and `languages/python/examples` dirs (though we are still grateful for any relevant disclosures there) - Social engineering, physical attacks, or denial-of-service - Attacks requiring privileged access to developer machines or CI/CD infrastructure diff --git a/biome.json b/biome.json index e6e9dd28f2..6b8b03c46f 100644 --- a/biome.json +++ b/biome.json @@ -21,6 +21,7 @@ "!packages/eql/tests/encryption/fixtures/stored_v4_rows_v1.json", "!packages/eql/tests/encryption/fixtures/v4_json_rows.json", "!packages/eql/tests/encryption/fixtures/stored_v4_json_rows_v1.json", + "!languages/python/crates/core/testdata/value-conformance.json", "!packages/eql/crates/eql-bindings/bindings", "!packages/eql/crates/eql-bindings/schema", "!packages/eql/packages/eql/src/generated", diff --git a/docs/adr/0002-one-value-model-three-encodings-and-eql-v4-by-producer.md b/docs/adr/0002-one-value-model-three-encodings-and-eql-v4-by-producer.md index ab2c4fff91..dee7a5e52e 100644 --- a/docs/adr/0002-one-value-model-three-encodings-and-eql-v4-by-producer.md +++ b/docs/adr/0002-one-value-model-three-encodings-and-eql-v4-by-producer.md @@ -400,6 +400,18 @@ without loss, and refuses it otherwise. - **JavaScript.** A `BigInt` takes the target's kind, and is refused if it does not fit. It decodes as a `BigInt`. `Date` means `Timestamp`; a date has its own wrapper. +- **Python.** The kind comes from the field's marker, never from the + annotation. The annotation must name exactly the one Python type the kind + takes (`int` for the integer kinds, `float` for the float kinds, + `decimal.Decimal`, `datetime.date`, `datetime.datetime`, `str`, `bool`), + and the class is refused when the two disagree. A value is checked by + exact type, so a `bool` is never an `int` and a `datetime` is never a + `date`, then by range: an `int` that does not fit the kind, a `float` + that does not round-trip through the kind's width, a naive `datetime` and + `None` are refused. Nothing is converted. A decrypted value comes back as + the same Python type. The rules are the Python SDK spec's (#1341, + "Values"). ADR-0001 under `languages/python/docs/adr/` records only the + decorator the annotation check runs in, not these rules. - **Decimal.** A host decimal (Go's `shopspring/decimal`, a JavaScript string) is refused if it is NaN or infinite, has more than 28 digits after the point, or does not fit a 96-bit mantissa at its scale. Postgres diff --git a/docs/fuzzing.md b/docs/fuzzing.md index d946e179d5..aae873397a 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -29,6 +29,7 @@ Current targets: | `fuzz:check-record` | `stack-encrypt` | `check_record` | `dynamic::record::check_record` over an `Arbitrary`-derived plan and tree, against a model of the record rules (structure-aware) | | `fuzz:plan-build` | `stack-encrypt` | `plan_build` | `plan::FieldsBuilder::build` over `Arbitrary`-derived untrusted names (context, fields, identities); a built field's label must render and parse back, and passthrough names are never refused (structure-aware) | | `fuzz:json-text` | `stack-encrypt` | `json_text` | `json::parse_text`, the boundary parser for a language SDK's JSON document, against serde_json: it reads what serde_json reads to the same value, refuses beyond it only repeated members and out-of-range integers, and what it reads writes back to itself | +| `fuzz:python-values` | `stackencrypt-core` | `value_frame` | the Python SDK's value conversion, `values::write` and `values::read`, both ways: a value the write check accepts must frame, read back as itself, and match the bytes `ValueFrame::of` gives the plain Rust type; arbitrary bytes read as a passthrough must never panic (structure-aware) | | `fuzz:json-wire` | `stack-encrypt` | `json_wire_decode` | the JSON index's wire decoders (`JsonDocument`, `JsonNode`, `JsonSelectorSet`, `JsonLeafQuery` from JSON text; `JsonHeader::from_bytes`), over base64 tags and base85 ciphertexts; an accepted document or node must round-trip through its wire form | Each target is a few lines — `libfuzzer-sys` hands a `&str` (or `&[u8]` @@ -69,6 +70,7 @@ packages/stack-auth/fuzz/ packages/stack-kms/fuzz/ packages/stack-encrypt/fuzz/ … +languages/python/crates/core/fuzz/ # the Python SDK's core crate ``` The committed `corpus//` seeds are valid examples of each format. @@ -128,10 +130,16 @@ Two jobs with deliberately different roles: `restore-keys`) so coverage compounds, minimized with `cargo fuzz cmin` to stay small, and any crash reproducer is uploaded as an artifact. -The `pull_request` trigger is path-filtered to `packages/stack-auth/**`, -`packages/stack-kms/**`, `packages/stack-encrypt/**`, the root `Cargo.toml`, -`Cargo.lock` and `mise.toml`, and the workflow file, with `!**.md` / `!**.example` excludes last so docs-only changes are -skipped. +The `pull_request` trigger is path-filtered to these paths: + +- `packages/stack-auth/**`, `packages/stack-kms/**`, and `packages/stack-encrypt/**` +- the other crates a target compiles: `packages/stack-profile/**`, `packages/stack-encrypt-derive/**`, and `packages/eql/crates/eql-bindings/**` +- `languages/python/crates/core/**`, `languages/python/Cargo.toml`, and `languages/golang/encrypt/guest/src/targets.rs` +- the root `Cargo.toml`, `Cargo.lock`, `mise.toml`, and `mise.test.toml` +- the workflow file + +The `!**.md` and `!**.example` excludes come last, so a change to documents only is skipped. +`scripts/__tests__/fuzz-workflow-paths.test.mjs` reads each fuzz crate's `Cargo.lock`. It fails when the filter misses a crate that a target compiles. ## Adding a new target diff --git a/docs/plans/2026-10-13-python-sdk-tracer-findings.md b/docs/plans/2026-10-13-python-sdk-tracer-findings.md new file mode 100644 index 0000000000..b57cb043dc --- /dev/null +++ b/docs/plans/2026-10-13-python-sdk-tracer-findings.md @@ -0,0 +1,298 @@ +# Python SDK tracer — findings from the first attempt + +> **Superseded on `next` (2026-10-20).** This is a snapshot of `main` on +> 13 October 2026. The engine has since moved on the `next` branch: a +> record crosses as a frame (`ValueFrame`), not an `FfiValue` tree; Stack +> Encrypt's EQL types are v4 only (`public.eql_v4_text_eq`), not the v3 +> domains this tracer stored into; most of the 51 EQL v4 types are +> producible (40 on 2026-10-20; `languages/golang/encrypt/eql/eql_gen.go` +> on `next` says which), not only `TextEq`; and the new kinds (`decimal`, +> `date`, `timestamp`) have landed. Read the engine sections as history. The +> Python and toolchain section (pyo3 0.29, maturin-only `extension-module`, +> libpython on the loader path, `Pending` as `IntoFuture`, width narrowing, +> the psycopg plumbing) still holds. The restart plan reads +> `packages/stack-encrypt/docs/language-integration.md` first; that guide +> is PR #1287, open against `next` as this header is written, and this +> document merges after it. + +**Status:** a proto-prototype, stopped by decision on 2026-10-13 after 7 of 9 +tasks, to be restarted fresh. The branch is kept as a reference under +`prototype/python-sdk-tracer`. **Do not merge it.** Read this file, then +the Python SDK discovery ticket, then start again from `next`. + +The code is disposable. What follows is not: it is what the code proved, +and most of it would take days to rediscover. + +## What was proven, live + +Against ZeroKMS (through the `stash auth login` profile) and a Postgres with +EQL v3.0.6 applied — 53 tests green, 3 Rust unit tests green: + +1. `import stackencrypt` from an `abi3-py39` wheel built by maturin over + `stack-encrypt`, in a fourth Cargo workspace at `languages/python`. +2. `Plan.of(User).context("users").build()` reads `Annotated` hints into the + engine's data plan, fails closed at `build()` on an undeclared field + (import time when that call sits at module level — by convention, not by + construction; see "Gaps against the discovery ticket's wording"), and the engine's + own `plan_with` validates the rest with no credentials. +3. `Cipher(plan).encrypt(models)` seals a batch in **one** ZeroKMS request; + an empty batch makes none; `decrypt` returns the model type; `query` + returns a term. +4. **Byte parity with Rust**, produced at test time: the shell opens what + Rust sealed, Rust opens what the shell sealed, and the equality term is + byte-identical. +5. **The whole six-step path through PostgreSQL**: insert into + `public.eql_v3_text_eq`, equality query through `eql_v3.query_text_eq`, + read back, decrypt. This is the "TextEq proven end-to-end in PostgreSQL" + that the catalog's storage-only `Text` domain records as its own blocker. + +Not done: CI (Task 8) and the scorecard (Task 9). + +## Findings about the engine and the catalog + +These are facts about `main` as of 2026-10-13, independent of any Python +design choice. + +- **`stack-encrypt`'s `dynamic` feature is the entire binding surface, and + it is off by default.** `stack_encrypt::dynamic::record` — the data plan, + `FfiValue`, `plan_with`, `encrypt_with`, `decrypt_with`, `query` — does not + exist without it. `eql-bindings`' own `stack-encrypt` feature takes the + crate with `default-features = false` and does **not** enable it; the + binding crate must. `http` (credential discovery) is a default and should + be named explicitly so it cannot be dropped silently. +- **The catalog produces exactly one EQL type: `TextEq`.** Every other entry + resolves `Unproducible`, with four recorded reasons: 42 types because + "how this family encodes a plaintext for the stack-encrypt producer + profile is not specified; only the text family's encoding is"; `TextMatch` + and the `Ope` types because "the engine derives match and OPE terms, but + no EQL type is built from them yet"; the ORE types because "EQL stores a + block ORE term and the engine derives a CLLW ORE term; the two are + different algorithms"; `Text` because it "follows once TextEq is proven + end-to-end in PostgreSQL". (Superseded: both are producible on `next`; + see open question 1.) **Consequence for the discovery ticket:** decision 9 lists + `equality`, `match`, `ore` as the engine features the four tasks need, and + backlog item 5 is "`match` and `ore` queries". Neither is a Python change; + both wait on the engine. Amend the ticket before anyone builds against it. +- **The engine-side design for that gap already exists:** "Value encodings + for new EQL kinds" (7 Oct 2026, the same day as the discovery ticket's + decisions; an internal design draft, since landed on `next` as + `vitaminc-ore` and the new kinds, so the engine source is now the + reference). What it established at the time: + - The 42 non-text families are tracked in **#1130** and are "blocked on + encoding, not on code". A kind must exist in **three** encodings owned + by three crates — the ciphertext tag (`vitaminc-aead-value`), the + equality domain (`vitaminc-prf`), the order encoding (`cllw-ore` / + `ore-rs`) — and today they disagree about which types exist (`i16` is in + two of three, so it cannot be a kind). + - The ORE refusal is **#1132**: EQL v3 stores **block ORE** for the + `_ord_ore` domains and **CLLW OPE** for `_ord` / `_ord_ope`; Stack + Encrypt derives CLLW ORE and CLLW OPE and no block ORE. That is why + `TextOrdOre` is refused although text is a kind. + - The proposal: a `vitaminc-ore` crate with one order-preserving plaintext + encoding (adopting `orderable-bytes`) and pluggable schemes (block ORE, + CLLW ORE, CLLW OPE); new kinds `Int16`, `Date`, `Timestamp`, `Decimal` + with new frozen tags from `0x0C`; a conformance test that fails unless + every kind has all three encodings. + - It does **not** cover `TextMatch`, whose blocker is different ("the + engine derives match terms, but no EQL type is built from them yet"). + So `match` is a smaller, separate, unticketed piece of work; `ore` is + this proposal. + - **It reaches Python directly.** The kind table is "a cross-language + commitment", and every new kind means a codec "in each binding": when + `Int16` / `Date` / `Timestamp` / `Decimal` land, the shell's + `convert.rs` grows an arm each, the width markers gain four names, and + `Date` versus `Timestamp` is the same declaration ambiguity it already + flags for Go's `time.Time`. The restart's declaration shape should + leave room for that rather than hard-code today's nine scalar kinds. +- **The engine core does more than the catalog.** Through the bare-outputs + path (`"outputs": ["c", "match"]`) it seals any kind and derives `eq`, + `match`, `ore`, `ope`. What is `TextEq`-only is the *framing* into an EQL + v3 column type, which is `eql-bindings`' catalog, not the engine. +- **`"type"` is required on every field with a term output** (#1096, now on + `main`), and `admits` decides which kinds an index accepts: `Equality` + takes the integer kinds, `string`, `bytes`; `Match` takes `string` only; + `Ore`/`Ope` take anything but arrays and objects. Kind names are + vitaminc's lowercase vocabulary (`string`, `int64`, `float64`, `bool`…), + parsed with `ValueKind: FromStr`; use that parser, never a table. +- **A stored record is a `CipherText` tree, not bytes.** Per row a `Map`; + per field a `Map` keyed by output: a target field's EQL JSON rides as a + passthrough `FfiValue::Bytes` under `"eql"`, a passthrough field's value + as a passthrough `FfiValue` under `"passthrough"`, a ciphertext as a + sealed subtree under `"c"`. The first two extract directly to column + values; the third needs a tree codec (the Go host has one). The tracer + refused bare-`"c"` fields by name and lost nothing. +- **Ciphertexts are never byte-equal** (fresh IV per seal). "The bytes + agree" can only mean the deterministic equality term; the ciphertext is + proven by cross-opening. +- **The EQL identifier `i` is stamped from the plan's context** at encrypt + time — `{"t": "", "c": ""}` — and is + authenticated into the ciphertext. `.context("")` and the SQL + table should be the same word. Postgres does not check it. +- **Credentials:** `StackCipher::new()` → `AutoStrategy`: `CS_CLIENT_ACCESS_KEY` + + `CS_WORKSPACE_CRN` (`auto_strategy.rs:120`), else the device session in + `~/.cipherstash//auth.json` (`:129-133`); the client + key likewise from `CS_CLIENT_ID`/`CS_CLIENT_KEY` or `secretkey.json`. A + logged-in developer machine needs nothing set. **Any committed bytes + keyed to a workspace keyset (equality terms) pass only in that + workspace.** Produce the Rust side of a parity check at test time instead. +- **`TextEq` needs nothing from Postgres that does not already exist**, which + is part of why it could ship first. Equality lives entirely in `hm`, the + HMAC; the ciphertext `c` is sealed with a fresh IV, so two stored values + for the same plaintext are *different* JSON and an index on the column + itself never matches. The index EQL v3 is built for is a functional one on + the extracted term — + `create index … using hash (eql_v3.eq_term(col))` (or `btree`: + the term's type rides on `bytea`'s default operator classes, so both work + and only `=` means anything) — and the bundle marks the extractor + `IMMUTABLE` and inlinable so the domain's `=` operator folds into the + query and the planner engages that index "structurally". The tracer + created **no** index on the encrypted column; its equality queries were + sequential scans. The ORE / OPE domains are the ones that need EQL's own + operator classes, because their terms do not sort as bytes. + +## Findings about Python and the toolchain + +- **pyo3 0.29, not 0.23**, for CPython 3.14. `abi3-py39` gives one wheel + per platform and builds against an interpreter newer than pyo3 knows. + Renames since the plan was written: `downcast` → `cast`, `allow_threads` + → `detach`, `PyObject` → `Py`. +- **`extension-module` must be maturin-only** (`[tool.maturin].features`), + not in `Cargo.toml`. On by default it stops `cargo test` and `cargo build` + linking (`Undefined symbols … _Py*`); maturin supplies + `-undefined dynamic_lookup`. `cargo check` is the compile check, + `maturin develop` the link check. +- **Anything that links pyo3 outside a wheel needs libpython on the loader + path** at run time (`DYLD_FALLBACK_LIBRARY_PATH` / `LD_LIBRARY_PATH` = + `sysconfig.get_config_var("LIBDIR")`), including `cargo test` and a + parity binary. CI must set it. +- **`Pending` is `IntoFuture`, not `Future`.** A sync shell is one Tokio + runtime, `into_future()` *before* `py.detach`, then `block_on`. There is + no second engine entry for sync, which keeps G6 reachable when async + comes. +- **Python's `int` has no width** and `34`/`34.0` derive different ORE + terms, so the annotation carries one (`Int64`, `Text`, …) and the shell + narrows exactly — stricter than `dynamic::read`, which converts lossless + widths and is right only for query literals. Check `bool` before `int`. + `None` encodes to `FfiValue::Null` and is refused by the declared kind. +- **The width markers should be a type, not a bare string**, or an unrelated + `Annotated[str, "a note"]` reads as a declaration. +- **psycopg plumbing** that touches no bytes: send a stored EQL value as + text with `::jsonb` in the SQL (`Jsonb(str)` wraps a JSON *string* and + fails the domain check); read it back as `email::text` and `.encode()` + (psycopg otherwise returns a `dict`). Postgres reorders keys; read by + name, never by position. +- **Environment quirks on this machine:** cargo and pip need the sandbox + off (shared `~/.cargo` writes; the proxy's TLS breaks pip and `gh`); + the repo's node scripts need node ≥ 20.11 (`import.meta.dirname`) — the + shell's is 20.3.1, mise has 24.11.1; `ghcr.io/cipherstash/postgres-eql` + needs a fresh `docker login ghcr.io`; the asdf `psql` shim has no + version, so install EQL through the container's own `psql`. + +## Design decisions made along the way (provisional — reviewers decide) + +- Targets resolve by **(kind, index)**, not by EQL name: Python writes + `{"kind": "string", "index": "eq"}` and the engine answers `TextEq` plus + both SQL domains. No EQL name is written in Python. An explicit + `EQL(name=...)` escape hatch exists. +- A **record is a dict of field name → column value** (EQL JSON as `bytes`, + passthrough as itself). It is what SQL wants, and no record ever needs a + serialised form. +- The **SQLAlchemy layer should be a separate PyPI distribution**, pure + Python, depending on the core wheel — the user's call on 2026-10-13; + not yet on the ticket. Backlog item 2 covers the call site, not the + shipping unit, so this is unowned. +- The live-test gate is "does the engine construct", not "are `CS_*` set". + +## Gaps against the discovery ticket's wording, found on re-reading + +Two places where what was built is narrower than what the ticket says, and +the findings above would otherwise imply the ticket was met. + +- **"The engine checks the declaration when Python defines the class" + (week-1 step 2) — not quite.** The check runs at `build()`, one call + later, wherever the caller puts that call. + `Plan.of(User).context(…).build()` at module scope runs at import time + **by convention**, because the call sits at module scope; nothing fires + when the `class` statement finishes. A class decorator — `@stash.model("users")` above `@dataclass` + — would run `build()` at class definition **by construction**, hold the + `PlanSpec` on the class, and let the call site be `Cipher(User)`. It is a + second way in (G6), so it has to earn its place: it carries the context, + which is per-model and which `Annotated` cannot express, and it reaches + the moment the ticket names. The stronger form makes it *the* way and + retires `Plan.of` from the public surface. Costs: decorator order + (`@dataclass(slots=True)` returns a new class, so a plan set below it is + lost; a transforming base such as Pydantic's or SQLAlchemy's declarative + may rewrite the class after it was read), and committing to "the model + class owns its plan". A plain `@dataclass` below the decorator is + **fine** — a class's annotations exist before `@dataclass` runs, so an + earlier claim that the wrong order "sees no fields" was mistaken. Backlog + item 1. +- **The model contract is not "a dataclass", and should not become one.** + psycopg imposes nothing — it sees tuples and parameters. The shell relies + on exactly three things: annotated fields readable through + `get_type_hints(cls, include_extras=True)`; attribute access (`getattr`) + on the way in; and a **keyword-argument constructor** matching the field + names on the way out (`model(**row)` in `decrypt`). Dataclasses meet that + with the least ceremony, which is the only reason the tracer used them. + So do `attrs`, Pydantic, `NamedTuple`, and SQLAlchemy declarative models + (default constructor takes kwargs) — the first user's actual classes. + Restricting to `@dataclass` would exclude them while enforcing more than + the code needs. State the contract explicitly (a `Protocol`), and let the + SQLAlchemy layer supply a base that meets it. `is_dataclass` is the cheap + guard for decorator order, not the right one. + +## Defects found in the plan (`2026-10-08-python-sdk-tracer.md`) + +All ruled on and ledgered; listed so the restart's plan does not repeat them: +missing `dynamic`/`http` features; pyo3 0.23; `cargo build` as a check; +`"Utf8Str"`-style kind names; `FfiValue::String(Vec)` (it is +`Utf8String`); an invented `Value::kind_of`; a committed workspace-keyed +fixture; "Python encrypted" wording (Python never encrypts — the check is +the engine *through the shell* against the engine bare); the record as +`list[bytes]`; `decrypt_with`'s fifth argument (`expected: Option
__``. + + It runs when SQLAlchemy maps the class, not when a column joins a table: + Alembic's ``add_column`` puts the new column on a temporary table and + creates every index of that table, so an index added there would be + created twice.""" + table = mapper.local_table + if not isinstance(table, Table): + return + declared = {_index_term(expression) for index in table.indexes for expression in index.expressions} + for column in table.columns: + if not isinstance(column.type, EqlType): + continue + for kind in column.type.marker.indexes: + if kind not in _INDEXES: + continue + function, method = _INDEXES[kind] + name = _index_name(f"ix_{table.name}_{column.name}_{kind}") + if any(index.name == name for index in table.indexes): + continue + # The model declares an index on this term itself, such as a + # unique btree on eq_term: that one serves the search. Compared + # as _index_term writes it: with no quotes, in lower case. + if f"eql_v4.{function}({column.name})".lower() in declared: + continue + Index(name, getattr(func.eql_v4, function)(column), postgresql_using=method) + + +# PostgreSQL's longest identifier, in bytes. +_MAX_NAME = 63 + + +def _index_name(name: str) -> str: + """``name``, or, when PostgreSQL would refuse it as too long, its start + and a short hash of the whole of it, so that two long names still + differ.""" + if len(name.encode()) <= _MAX_NAME: + return name + digest = hashlib.sha256(name.encode()).hexdigest()[:8] + start = name.encode()[: _MAX_NAME - len(digest) - 1].decode(errors="ignore") + return f"{start}_{digest}" + + +def _index_term(expression: Any) -> str: + """An index expression as SQL, with no spaces or quotes, such as + ``eql_v4.eq_term(email)``.""" + try: + sql = str(expression.compile(dialect=postgresql.dialect())) + except Exception: # an expression only its own dialect compiles + return "" + return "".join(sql.split()).replace('"', "").lower() + + +def render_item(type_: str, obj: Any, autogen_context: Any) -> Any: + """For Alembic's ``context.configure(render_item=render_item)`` in + ``env.py``. Writes an ``EqlType`` column type into a migration as + ``stackencrypt.sqlalchemy.EqlType(stackencrypt.TextEq)``, and adds the + imports it needs. Returns ``False`` for anything else, so that Alembic + writes it as usual.""" + if type_ == "type" and isinstance(obj, EqlType): + autogen_context.imports.add("import stackencrypt") + autogen_context.imports.add("import stackencrypt.sqlalchemy") + name = obj.marker.__name__ + # A marker the package does not export, such as Json, is in _eql. + module = "stackencrypt" if hasattr(stackencrypt, name) else "stackencrypt._eql" + return f"stackencrypt.sqlalchemy.EqlType({module}.{name})" + return False + + +def process_revision_directives(context: Any, revision: Any, directives: List[Any]) -> None: + """For Alembic's ``context.configure(process_revision_directives=...)`` + in ``env.py``. Rewrites each upgrade step that changes an existing + column to an EQL type. + + Alembic writes that change as ``op.alter_column(..., type_=EqlType(...))``, + and PostgreSQL refuses it: there is no cast from a column's old type to + an EQL type. The step becomes ``op.execute`` of an ALTER with + ``USING ::jsonb::``. That changes the type of a column + with no rows. A column with rows that are not EQL values fails the + migration, and keeps its rows. Any other change to the same column, such + as to ``nullable``, is kept as Alembic wrote it. + + The downgrade steps are not changed. + """ + from alembic.operations import ops + + preparer = context.dialect.identifier_preparer + for script in directives: + for container, rewrite in ( + (script.upgrade_ops, lambda step: _rewrite_type_change(ops, preparer, step)), + (script.downgrade_ops, lambda step: [step]), + ): + kept = [] + for table_ops in container.ops: + if isinstance(table_ops, ops.ModifyTableOps): + table_ops.ops = [ + step + for operation in table_ops.ops + for unchanged in _drop_same_eql_type(ops, operation) + for step in rewrite(unchanged) + ] + if not table_ops.ops: + continue + kept.append(table_ops) + container.ops = kept + + +def _drop_same_eql_type(ops: Any, operation: Any) -> List[Any]: + """Alembic compares a column's type as text: the database gives an EQL + column's type as ``eql_v4_text_eq``, and ``EqlType`` as + ``public.eql_v4_text_eq``. So it finds a change where there is none. + Drop that type change, and the step if it changes nothing else.""" + if not isinstance(operation, ops.AlterColumnOp) or operation.modify_type is None: + return [operation] + if not _same_eql_type(operation.existing_type, operation.modify_type): + return [operation] + operation.modify_type = None + return [operation] if operation.has_changes() else [] + + +def _same_eql_type(a: Any, b: Any) -> bool: + for eql, other in ((a, b), (b, a)): + if isinstance(eql, EqlType): + name = eql.marker.sql_domain.rsplit(".", 1)[-1] + if isinstance(other, EqlType): + return other.marker is eql.marker + return getattr(other, "name", None) == name and getattr(other, "schema", None) in ( + None, + "public", + ) + return False + + +def _rewrite_type_change(ops: Any, preparer: Any, operation: Any) -> List[Any]: + if not ( + isinstance(operation, ops.AlterColumnOp) + and isinstance(operation.modify_type, EqlType) + and not _is_eql(operation.existing_type) + ): + return [operation] + table = preparer.quote(operation.table_name) + if operation.schema: + table = f"{preparer.quote_schema(operation.schema)}.{table}" + column = preparer.quote(operation.column_name) + domain = operation.modify_type.marker.sql_domain + steps = [ + ops.ExecuteSQLOp( + f"ALTER TABLE {table} ALTER COLUMN {column} TYPE {domain} " + f"USING {column}::jsonb::{domain}" + ) + ] + operation.existing_type = operation.modify_type + operation.modify_type = None + if operation.has_changes(): + steps.append(operation) + return steps + + +def _is_eql(column_type: Any) -> bool: + """An EQL type, from the model or as the database gives it.""" + if isinstance(column_type, EqlType): + return True + return str(getattr(column_type, "name", "")).startswith("eql_v4_") + + +# The configured tables, for the comparisons: a Table to its cipher. +_TABLES: Dict[Table, Cipher] = {} + +# The configured tables, by their Table object: the class configured for each. +_TABLE_CLASSES: Dict[Table, type] = {} + +# The objects a load built or refreshed, and which attributes, waiting for +# the do_orm_execute hook. Kept in each session's `info`. +_PENDING = "stackencrypt_pending" + +# Every class configured in this process, with its cipher and sealed fields. +_CONFIGURED: Dict[type, Tuple[Cipher, FrozenSet[str]]] = {} + + +def configure_encryption(classes: Sequence[type]) -> None: + """Encrypt and decrypt the declared fields of ``classes`` when the ORM + writes them through any engine, and reads them in any session. + + Each class must be declared with ``stackencrypt.model`` and mapped by + SQLAlchemy. Its declared table must be its ``__tablename__``, and each + sealed field's column must have the field's name. + + The hooks are installed once in a process, on SQLAlchemy's ``Engine`` + and ``Session`` classes, so every engine gets them, including one made + later, such as a test's. A write is encrypted when its table is the + ``Table`` object of a configured class. + + A repeat call for a class that is already configured does nothing. + It still raises ``DeclarationError`` when the class changed since it + was declared, or when another class is configured for its table. + + A subclass that SQLAlchemy maps to its base's table, by single-table + inheritance, is encrypted and decrypted as its base is. It needs no + declaration of its own, and listing it with its base, or after it, does + nothing. + + Every class is checked first. A class that fails raises + ``DeclarationError``, and none of the classes is configured. The check + makes no request. + """ + listed = set(classes) + checked = [ + _check(cls) # refuses a changed class too + for cls in classes + if _covering_class(cls, listed) is None + ] + owners = dict(_TABLE_CLASSES) + new = [] + for cls, cipher, sealed in checked: + table = inspect(cls).local_table + owner = owners.get(table) + if owner is not None and owner is not cls: + raise DeclarationError( + "declaration_already_configured", + f"{cls.__qualname__} maps table {table.name!r}, and " + f"{owner.__qualname__} is configured for that table already. " + "Configure one class for each table.", + ) + if owner is None: + owners[table] = cls + new.append((cls, cipher, sealed, table)) + for cls, cipher, sealed, table in new: + _CONFIGURED[cls] = (cipher, sealed) + _TABLES[table] = cipher + _TABLE_CLASSES[table] = cls + # propagate: a single-table subclass's objects are loaded as its own. + event.listen(cls, "load", _remember_load, propagate=True) + event.listen(cls, "refresh", _remember_refresh, propagate=True) + if not event.contains(Engine, "before_execute", _encrypt_parameters): + event.listen(Engine, "before_execute", _encrypt_parameters, retval=True) + if not event.contains(Session, "do_orm_execute", _decrypt_loaded): + event.listen(Session, "do_orm_execute", _decrypt_loaded) + + +def _covering_class(cls: type, listed: Any = ()) -> Optional[type]: + """The configured or listed base class whose table ``cls`` is mapped to, + by single-table inheritance, or ``None``.""" + mapper = inspect(cls, raiseerr=False) if isinstance(cls, type) else None + table = getattr(mapper, "local_table", None) + for base in cls.__mro__[1:] if isinstance(cls, type) else (): + if base in _CONFIGURED or base in listed: + base_mapper = inspect(base, raiseerr=False) + if table is not None and getattr(base_mapper, "local_table", None) is table: + return base + return None + + +def _configured_for(cls: type) -> Tuple[Cipher, FrozenSet[str]]: + """The cipher and sealed fields of ``cls``, or of the configured base + that a single-table subclass is mapped as.""" + for base in cls.__mro__: + if base in _CONFIGURED: + return _CONFIGURED[base] + raise KeyError(cls) + + +def _check(cls: type) -> Tuple[type, Cipher, FrozenSet[str]]: + cipher = Cipher(cls) # refuses a class that is not declared + mapper = inspect(cls, raiseerr=False) if isinstance(cls, type) else None + if mapper is None or not hasattr(mapper, "columns"): + raise DeclarationError( + "declaration_not_mapped", + f"{cls.__qualname__} is not mapped by SQLAlchemy. configure_encryption " + "takes ORM classes. Use Cipher directly for other classes.", + ) + table = cipher.declaration.table + mapped = getattr(mapper.local_table, "name", None) + if mapped != table: + raise DeclarationError( + "declaration_table_mismatch", + f"{cls.__qualname__} is declared for table {table!r}, but SQLAlchemy " + f"maps it to {mapped!r}. Use the same name in stackencrypt.model and " + "__tablename__.", + ) + sealed = frozenset( + field.name for field in cipher.declaration.fields if field.marker is not Passthrough + ) + for name in sorted(sealed): + column = mapper.columns.get(name) + if column is None or column.name != name or column.key != name: + found = None if column is None else column.name + raise DeclarationError( + "declaration_column_name", + f"field {name!r} of {cls.__qualname__} is stored in column {found!r}. " + "configure_encryption needs each encrypted field's column to have " + "the field's name.", + field=name, + ) + for field in cipher.declaration.fields: + if field.marker is Passthrough: + continue + column = mapper.columns[field.name] + table = mapper.local_table + unique = ( + column.unique + or column.primary_key + or any( + # contains_column, not `in`: a column collection takes only + # a string key with `in`, and raises ArgumentError for a + # Column. A primary key is a unique constraint too. + isinstance(constraint, (UniqueConstraint, PrimaryKeyConstraint)) + and constraint.columns.contains_column(column) + for constraint in table.constraints + ) + # An index on the column itself, not on a term of it. + or any( + index.unique and any(expression is column for expression in index.expressions) + for index in table.indexes + ) + ) + if unique: + raise DeclarationError( + "declaration_unique_column", + f"field {field.name!r} of {cls.__qualname__} is an encrypted column in a " + "primary key, or with a unique constraint or index. Each EQL value contains " + "new random ciphertext, so the database never finds a duplicate. Use a " + "unique index on its equality term instead: " + f"Index(..., text(\"eql_v4.eq_term({field.name})\"), unique=True) in " + "__table_args__.", + field=field.name, + ) + column_type = column.type + if not (isinstance(column_type, EqlType) and column_type.marker is field.marker): + raise DeclarationError( + "declaration_column_type", + f"field {field.name!r} of {cls.__qualname__} is declared " + f"{field.marker.__name__}, so its column must be " + f"{field.marker.sql_domain}, but SQLAlchemy maps it as {column_type!r}. " + "Set `type_annotation_map = eql_type_map` on the declarative base, " + "and give the column no type of its own.", + field=field.name, + ) + return cls, cipher, sealed + + +def _encrypt_parameters(conn, clause, multiparams, params, execution_options): # type: ignore[no-untyped-def] + """The write hook, on SQLAlchemy's ``Engine`` class: every engine runs it.""" + if execution_options.get(_SCANNED): + # A select the session hook checked and sorted already. + return clause, multiparams, params + matches, sorted_ = _scan(clause) + if matches and not execution_options.get(_VERIFIED): + column, _ = matches[0]._annotations[_MATCH] + raise _match_refused(column, "is not run through a session.") + if isinstance(clause, (Select, CompoundSelect)): + # Every select, ORM-marked or not. SQLAlchemy marks a select as + # ORM when it names a mapped class or attribute, even when it + # runs on a connection, where the session hook does not run. + return (_sorted_by_terms(clause) if sorted_ else clause), multiparams, params + if not isinstance(clause, (Insert, Update)): + return clause, multiparams, params + cls = _configured_class(clause.table) + if cls is None: + return clause, multiparams, params + cipher, sealed = _CONFIGURED[cls] + rows = [dict(row) for row in multiparams] if multiparams else [dict(params or {})] + _refuse_inline_values(clause, sealed, rows) + # Passthrough fields too: encrypt_rows checks them as encrypt does, + # and returns them unchanged. + names = _declared(cipher).intersection(rows[0]) + if not names: + return clause, multiparams, params + encrypted = cipher.encrypt_rows([{name: row[name] for name in names} for row in rows]) + for row, values in zip(rows, encrypted): + row.update(values) + if multiparams: + return clause, rows, params + return clause, multiparams, rows[0] + + +def _configured_class(table: Any) -> Any: + """The class configured for ``table``, found by its Table object, not + its name: another database's table of the same name is not encrypted. + An ORM statement can carry an annotated copy of the Table, so the copy + is also looked up as its original.""" + found = _TABLE_CLASSES.get(table) + if found is None and hasattr(table, "_deannotate"): + found = _TABLE_CLASSES.get(table._deannotate()) + return found + + +def _declared(cipher: Cipher) -> FrozenSet[str]: + return frozenset(field.name for field in cipher.declaration.fields) + + +def _refuse_inline_values(clause: Any, sealed: FrozenSet[str], rows: List[Dict[str, Any]]) -> None: + """Refuse a statement that carries a sealed value in its own SQL, in any + form: ``values(email=...)``, a list of rows in ``values([...])``, a SQL + expression that holds a value, such as ``func.lower(...)`` or ``case``, + a ``bindparam`` of another name, or the SET of an ON CONFLICT. The hook + encrypts only the parameters passed beside the statement under the + field's own name, so any other value would be stored as plaintext. + + The statement's values are read as it holds them, not compiled: a + compile here would run outside SQLAlchemy's compiled cache.""" + for name, value, own_name in _written_values(clause): + if name not in sealed: + continue + overridden = own_name and any(name in row for row in rows) + if not overridden and _sends_value(value, name if own_name else None): + raise DeclarationError( + "declaration_inline_value", + f"the statement writes {name!r} of table {clause.table.name!r} as a value " + "inside the SQL, so it cannot be encrypted. Pass the values as " + "parameters, as session.add and session.execute(insert(...), rows) do.", + field=name, + ) + + +def _written_values(clause: Any) -> List[Tuple[str, Any, bool]]: + """Each value the statement holds itself, as its column's name, the value, + and whether the parameter of the field's own name sets it: that is true + of ``values(...)`` with one row, whose parameter takes the column's name, + and of nothing else.""" + found: List[Tuple[str, Any, bool]] = [] + columns = list(clause.table.columns) + + def name_of(key: Any) -> str: + return key if isinstance(key, str) else getattr(key, "key", str(key)) + + for key, value in (getattr(clause, "_values", None) or {}).items(): + found.append((name_of(key), value, True)) + for key, value in getattr(clause, "_ordered_values", None) or (): + found.append((name_of(key), value, True)) + for rows in getattr(clause, "_multi_values", None) or (): + for row in rows: + items = row.items() if isinstance(row, dict) else zip(columns, row) + for key, value in items: + found.append((name_of(key), value, False)) + # The SET of an ON CONFLICT DO UPDATE, on PostgreSQL and SQLite. + on_conflict = getattr(clause, "_post_values_clause", None) + sets = getattr(on_conflict, "update_values_to_set", None) or {} + for key, value in sets.items() if isinstance(sets, dict) else sets: + found.append((name_of(key), value, False)) + return found + + +def _sends_value(value: Any, own_name: Optional[str]) -> bool: + """Whether ``value`` puts a Python value into the SQL. A NULL does not, + and neither does a column, such as ``excluded.email``. Where the + field's own parameter sets the value, ``own_name`` is the field's name, + and ``bindparam(own_name)`` takes that parameter, which the hook + encrypts.""" + if not isinstance(value, ClauseElement): + return value is not None + for element in visitors.iterate(value): + if not isinstance(element, BindParameter): + continue + if element.callable is not None or element.value is not None: + return True + # A placeholder of its own name would take a parameter the hook + # does not encrypt. An anonymous one is a NULL. + if not isinstance(element.key, _anonymous_label) and not (element is value and element.key == own_name): + return True + return False + + +def _remember_load(target: object, context: object) -> None: + _remember(target, None) + + +def _remember_refresh(target: object, context: object, attrs: Any) -> None: + _remember(target, attrs) + + +def _remember(target: object, attrs: Any) -> None: + session = Session.object_session(target) + if session is not None: + names = None if attrs is None else frozenset(attrs) + session.info.setdefault(_PENDING, []).append((target, names)) + + +def _decrypt_loaded(state: Any) -> Any: + matches, sorted_ = _scan(state.statement) + if matches and not state.is_select: + column, _ = matches[0]._annotations[_MATCH] + raise _match_refused(column, "is in an UPDATE or a DELETE, so it would change rows that do not hold the text.") + if not state.is_select: + return None + searches = _checked_matches(state.statement, matches) + if sorted_: + state.statement = _sorted_by_terms(state.statement) + # The engine hook need not scan the statement again. + options: Dict[str, Any] = {_SCANNED: True} + if searches: + options[_VERIFIED] = True + result = state.invoke_statement(execution_options=options) + positions = _encrypted_columns(state.statement) + chunks = getattr(result, "chunks", None) + if chunks is None: + # Not the ORM's own result, such as one another hook made: read it + # whole. + frozen = result.freeze() + rows = _opened(state.session, positions, searches, list(frozen.data)) + return frozen.with_new_rows([_as_tuple(row) for row in rows])() + + # The ORM builds the rows a chunk at a time, as the app reads them: all + # of them for .all(), and yield_per's number under yield_per. Each + # chunk's objects are decrypted when the chunk is built, after their + # load events have fired and before the app sees them. Uniqueness, and + # SQLAlchemy's refusal of a joined eager load with no .unique(), still + # apply to the rows as the ORM gives them. + def opened_chunks(size: Optional[int]) -> Any: + for rows in chunks(size): + yield _opened(state.session, positions, searches, list(rows)) + + result.chunks = opened_chunks + result.iterator = itertools.chain.from_iterable(opened_chunks(result._yield_per)) + return result + + +def _as_tuple(row: Any) -> Any: + return row if isinstance(row, (tuple, Row)) else (row,) + + +def _opened(session: Any, positions: Dict[Cipher, Dict[int, str]], searches: List[Any], rows: List[Any]) -> List[Any]: + """``rows`` with the objects loaded for them decrypted, the encrypted + result columns decrypted, and the rows that fail a match search + removed.""" + pending = session.info.pop(_PENDING, []) + groups: Dict[Tuple[type, FrozenSet[str]], List[object]] = {} + for target, names in pending: + cipher, sealed = _configured_for(type(target)) + loaded = target.__dict__ + fields = frozenset( + name + for name in (sealed if names is None else sealed.intersection(names)) + if loaded.get(name) is not None + ) + if fields: + groups.setdefault((type(target), fields), []).append(target) + for (cls, fields), targets in groups.items(): + cipher, _ = _configured_for(cls) + opened = cipher.decrypt_rows( + [{name: target.__dict__[name] for name in fields} for target in targets] + ) + for target, values in zip(targets, opened): + for name, value in values.items(): + set_committed_value(target, name, value) + if positions: + rows = _decrypt_columns(positions, rows) + if searches: + rows = _verify(searches, rows) + return rows + + +# A match search the hook verifies: the result position of its class's +# objects, the field, and the MatchQuery. +_Search = Tuple[int, str, Any] +# A condition of the WHERE, joined to the others with AND: an OR of +# branches, each an AND of searches. A plain match is one branch of one. +_Condition = List[List[_Search]] + + +def _checked_matches(statement: Any, matches: List[Any]) -> List[_Condition]: + """The match searches of ``statement``, as conditions the hook can + verify. Raises ``QueryError`` for a search whose rows cannot be + verified. + + A search can be verified when it is a condition of the WHERE joined + with AND, or a branch of an ``or_`` whose branches are all match + searches or ``and_`` of them. A row that passes ``verify`` always + passes ``@@``, so the database returns every row such an ``or_`` + keeps, and ``verify`` on each branch removes the rest.""" + if not matches: + return [] + descriptions = statement.column_descriptions + placed: Dict[int, _Search] = {} + + def search(match: Any) -> _Search: + column, query = match._annotations[_MATCH] + cls = next((c for c, (cipher, _) in _CONFIGURED.items() if cipher is _cipher_of(column)), None) + at = next( + ( + i + for i, description in enumerate(descriptions) + # A single-table subclass's objects are its base's too. + if isinstance(description.get("type"), type) + and cls is not None + and issubclass(description["type"], cls) + and description.get("expr") is description.get("type") + ), + None, + ) + if at is None: + raise _match_refused(column, "has no whole objects of its class in the result.") + placed[id(match)] = (at, column.key, query) + return placed[id(match)] + + conditions: List[_Condition] = [] + # The conditions as the app passed them to where(), with each and_() + # opened. `whereclause` would join copies of them. + for condition in _joined(list(getattr(statement, "_where_criteria", ()) or [statement.whereclause]), operators.and_): + if _is_match(condition): + conditions.append([[search(condition)]]) + continue + branches = _joined([condition], operators.or_) if _is_bool(condition, operators.or_) else None + if branches is None: + continue + ands = [_joined([branch], operators.and_) for branch in branches] + if all(part and all(_is_match(match) for match in part) for part in ands): + conditions.append([[search(match) for match in part] for part in ands]) + for match in matches: + if id(match) not in placed: + column, _ = match._annotations[_MATCH] + raise _match_refused( + column, + "is not a condition of the WHERE joined with AND, or a branch of an or_ " + "of match searches only.", + ) + return conditions + + +def _ungrouped(element: Any) -> Any: + while isinstance(element, Grouping): + element = element.element + return element + + +def _is_bool(element: Any, operator: Any) -> bool: + return isinstance(element, BooleanClauseList) and element.operator is operator + + +def _is_match(element: Any) -> bool: + return _MATCH in getattr(element, "_annotations", {}) + + +def _joined(elements: List[Any], operator: Any) -> List[Any]: + """``elements`` with each group removed, and each clause list of + ``operator`` opened into its clauses, at any depth.""" + out = [] + pending = list(reversed(elements)) + while pending: + element = _ungrouped(pending.pop()) + if _is_bool(element, operator): + pending.extend(reversed(list(element.clauses))) + else: + out.append(element) + return out + + +def _item(row: Any, at: int) -> Any: + """Item ``at`` of a frozen row. A result of one object, as from + ``select(Customer)``, keeps the object itself, not a one-item row.""" + return row[at] if isinstance(row, (tuple, Row)) else row + + +def _verify(conditions: List[_Condition], rows: List[Any]) -> List[Any]: + """Keep the rows that pass every condition: for each, at least one + branch whose match searches all pass ``verify``. A field that is NULL + fails its search, and so does an object an outer join leaves out.""" + passed: Dict[int, List[bool]] = {} + + def results(search: _Search) -> List[bool]: + if id(search) not in passed: + at, field, query = search + # An outer join gives None for a missing object. Its search fails, + # as for a NULL value. + values = [getattr(_item(row, at), field, None) for row in rows] + present = [i for i, value in enumerate(values) if value is not None] + ok = [False] * len(rows) + for i, verified in zip(present, query.verify([values[i] for i in present])): + ok[i] = verified + passed[id(search)] = ok + return passed[id(search)] + + keep = [ + all( + any(all(results(search)[i] for search in branch) for branch in condition) + for condition in conditions + ) + for i in range(len(rows)) + ] + return [row for row, kept in zip(rows, keep) if kept] + + +def _encrypted_columns(statement: Any) -> Dict[Cipher, Dict[int, str]]: + """The result columns of ``statement`` that are encrypted fields, such + as ``email`` in ``select(Customer.email, Customer.plan)``: their + positions and field names, by cipher. A column of a whole object, as in + ``select(Customer)``, is left to the load events.""" + positions: Dict[Cipher, Dict[int, str]] = {} + for at, description in enumerate(getattr(statement, "column_descriptions", None) or ()): + column = _column_of(description.get("expr")) + if column is None or not isinstance(column.type, EqlType): + continue + cipher = _TABLES.get(getattr(column, "table", None)) + if cipher is not None: + positions.setdefault(cipher, {})[at] = column.key + return positions + + +def _decrypt_columns(positions: Dict[Cipher, Dict[int, str]], rows: List[Any]) -> List[Any]: + """``rows`` with the encrypted columns at ``positions`` decrypted: with + one ``decrypt_rows`` call for each class and set of fields that are not + NULL. A row of one column, as from ``select(Customer.email)``, is the + value itself.""" + single = bool(rows) and not isinstance(rows[0], (tuple, Row)) + lists = [[row] if single else list(row) for row in rows] + for cipher, fields in positions.items(): + groups: Dict[FrozenSet[int], List[List[Any]]] = {} + for row in lists: + present = frozenset(at for at in fields if row[at] is not None) + if present: + groups.setdefault(present, []).append(row) + for present, members in groups.items(): + opened = cipher.decrypt_rows( + [{fields[at]: row[at] for at in present} for row in members] + ) + for row, values in zip(members, opened): + for at in present: + row[at] = values[fields[at]] + return [row[0] if single else tuple(row) for row in lists] + + +def _column_of(expression: Any) -> Any: + """The table column behind a result column: through an ORM attribute's + clause element, and through a label.""" + if hasattr(expression, "__clause_element__"): + expression = expression.__clause_element__() + while hasattr(expression, "element") and not isinstance(getattr(expression, "table", None), Table): + expression = expression.element + return expression if isinstance(getattr(expression, "table", None), Table) else None + + +def _term(column: Any, method: str, value: object) -> Any: + """``value`` as the query term that ``method`` makes for ``column``, + cast to the column marker's query type.""" + term = getattr(_cipher_of(column), method)(column.key, value) + return cast(literal(term, String()), _QueryType(column.type.marker.query_sql_domain)) + + +def _sort_key(column: Any) -> Any: + """What to sort an encrypted column by: its order term, such as + ``eql_v4.ord_term(col)``. Raises ``QueryError`` for a column with no + order index. + + A bare ``ORDER BY col`` sorts EQL v4 values in a wrong order, with no + error: the EQL column types have no sort order of their own, so + PostgreSQL sorts the stored JSON. The order term sorts by the + plaintext, and the index on it serves the sort.""" + function = next( + (_ORDER_TERMS[kind] for kind in column.type.marker.indexes if kind in _ORDER_TERMS), None + ) + if function is None: + raise QueryError( + "query_unsupported", + f"the encrypted column {_column_name(column)} has no order index, so it cannot " + f"be sorted. Its marker, {column.type.marker.__name__}, has the indexes " + f"{list(column.type.marker.indexes)}. Use a marker with an order index, such as " + "IntegerOrd or TextOrd.", + field=getattr(column, "key", None), + reason="order", + ) + return getattr(func.eql_v4, function)(column) + + +def _sorted_by_terms(statement: Any) -> Any: + """``statement`` with each encrypted column in an ORDER BY replaced by + its order term, in every select of it, subqueries included. A bare + ``order_by(col)``, or ``sqlalchemy.asc(col)``, does not go through the + column type's operators, so the hooks rewrite it here. The statement is + copied only when an ORDER BY changes.""" + if not any( + _select_sort_keys(element) is not element._order_by_clauses + for element in visitors.iterate(statement) + if isinstance(element, Select) and element._order_by_clauses + ): + return statement + + def visit_select(select: Any) -> None: + # On the copy that cloned_traverse made, as SQLAlchemy's own + # generative methods change it. + if select._order_by_clauses: + select._order_by_clauses = _select_sort_keys(select) + + return visitors.cloned_traverse(statement, {}, {"select": visit_select}) + + +# The elements with an ORDER BY of their own that the hooks do not rewrite: +# a window, WITHIN GROUP, and an aggregate's ORDER BY. +_OWN_ORDER_BY = frozenset({"over", "withingroup", "aggregateorderby", "aggregate_order_by"}) + + +def _scan(statement: Any) -> Tuple[List[Any], bool]: + """One walk of ``statement``: its match searches, and whether any select + in it has an ORDER BY for ``_sorted_by_terms`` to look at. Raises + ``QueryError`` for an encrypted column sorted where the hooks cannot + sort it by its order term.""" + matches = [] + sorted_ = False + for element in visitors.iterate(statement): + if _MATCH in getattr(element, "_annotations", {}): + matches.append(element) + if isinstance(element, Select) and element._order_by_clauses: + sorted_ = True + elif isinstance(element, CompoundSelect) and element._order_by_clauses: + _refuse_compound_sort(element) + elif getattr(element, "__visit_name__", None) in _OWN_ORDER_BY: + column = _bare_eql_column(getattr(element, "order_by", None)) + if column is not None: + raise _sort_refused( + column, + "in a window, WITHIN GROUP, or an aggregate's ORDER BY. Sort it by " + "its order term: pass Model.field.asc() or .desc() instead.", + ) + return matches, sorted_ + + +def _refuse_compound_sort(compound: Any) -> None: + """A UNION's ORDER BY names its result columns, so it cannot sort by an + order term. Refuse an encrypted column there, named or by a string.""" + named = {} + if compound.selects: + named = dict(compound.selects[0].selected_columns.items()) + for clause in compound._order_by_clauses: + column = _bare_eql_column(clause) + if column is None: + for element in visitors.iterate(clause): + if isinstance(element, (_label_reference, _textual_label_reference)): + name = element.element + found = named.get(name) if isinstance(name, str) else None + if _is_eql_column(found): + column = found + if column is not None: + raise _sort_refused( + column, + "in the ORDER BY of a UNION, or another compound select. That ORDER BY " + "sorts the result columns, so it cannot sort by the order term. Sort " + "each select, or select the order term as a column and sort by it.", + ) + + +def _bare_eql_column(clause: Any) -> Any: + """An encrypted column in ``clause`` that is not inside an order term, + or ``None``.""" + pending = [clause] if clause is not None else [] + while pending: + element = pending.pop() + if isinstance(element, FunctionElement) and getattr(element, "name", None) in _ORDER_TERMS.values(): + continue + if _is_eql_column(element): + return element + pending.extend(element.get_children()) + return None + + +def _sort_refused(column: Any, where: str) -> QueryError: + return QueryError( + "query_unsupported", + f"the encrypted column {_column_name(column)} is sorted {where} A bare sort of an " + "EQL column sorts the stored JSON, in a wrong order.", + field=getattr(column, "key", None), + reason="order", + ) + + +def _select_sort_keys(select: Any) -> Any: + """The ORDER BY of ``select`` with each encrypted column replaced by its + order term; the same object when nothing changes. A label or a name in + the ORDER BY, as in ``order_by(col.label("x"))``, ``order_by("x")`` or + ``order_by(desc("level"))``, would sort by the selected column, so it is + replaced by the order term of the column it names.""" + clauses = select._order_by_clauses + # What a string in the ORDER BY names, as SQLAlchemy matches it when it + # compiles the statement: a label's name first, then a selected + # column's key, then a column of a FROM table. Every candidate is kept, + # plaintext ones included, so that a plaintext label hides an encrypted + # column of the same name. Only a name that resolves to an encrypted + # column is rewritten. A name that two FROM tables share, with an + # encrypted column among them, cannot be told apart, so it is refused. + named: Dict[str, Any] = {} + candidates: Dict[str, List[Any]] = {} + for table in select.get_final_froms(): + for column in getattr(table, "c", ()): + named[column.key] = column + candidates.setdefault(column.key, []).append(column) + shared = { + name + for name, columns in candidates.items() + if len({id(c) for c in columns}) > 1 and any(_is_eql_column(c) for c in columns) + } + for key, column in select.selected_columns.items(): + named[key] = column + shared.discard(key) + for column in getattr(select, "_raw_columns", ()): + if isinstance(column, Label): + named[column.name] = column.element + shared.discard(column.name) + + def replace(element: Any) -> Any: + if isinstance(element, FunctionElement) and getattr(element, "name", None) in _ORDER_TERMS.values(): + return element # already a sort key: not opened again + if isinstance(element, (_label_reference, _textual_label_reference)): + name = element.element + if isinstance(name, str) and name in shared: + raise QueryError( + "query_unsupported", + f"order_by({name!r}) names an encrypted column of more than one table " + "in the query, so the SDK cannot sort by its order term. Pass the " + "column itself, such as order_by(Model.field).", + reason="order", + ) + column = named.get(name) if isinstance(name, str) else _labelled_column(name) + return _sort_key(column) if _is_eql_column(column) else None + column = _labelled_column(element) + if column is not None: + return _sort_key(column) + # Any column of an EQL type: of a table, an alias, or a subquery. + if _is_eql_column(element): + return _sort_key(element) + return None + + rewritten = tuple(visitors.replacement_traverse(clause, {}, replace) for clause in clauses) + return clauses if all(new is old for new, old in zip(rewritten, clauses)) else rewritten + + +def _is_eql_column(element: Any) -> bool: + return isinstance(element, ColumnClause) and isinstance(element.type, EqlType) + + +def _labelled_column(element: Any) -> Any: + """The encrypted column inside a label, or ``None``.""" + if isinstance(element, Label) and _is_eql_column(element.element): + return element.element + return None + + +def _cipher_of(column: Any) -> Cipher: + cipher = _TABLES.get(getattr(column, "table", None)) + if cipher is None: + raise QueryError( + "query_not_configured", + f"column {_column_name(column)} is encrypted, but its class is not configured. " + "Call configure_encryption with the class before you build a query on it.", + ) + return cipher + + +def _match_refused(column: Any, why: str) -> QueryError: + return QueryError( + "query_unsupported", + f"a match search on {_column_name(column)} {why} A match index can return rows " + "that do not hold the text, and the SDK removes them after the query. It can do " + "that only in a select run through a session, with the match as a condition of " + "the WHERE, joined with AND, and with whole objects of the class in the result.", + field=getattr(column, "key", None), + reason="match", + ) + + +def _refused(column: Any, op: Callable[..., Any]) -> QueryError: + name = getattr(op, "__name__", repr(op)).removesuffix("_op") + return QueryError( + "query_unsupported", + f"{name} is not supported on the encrypted column {_column_name(column)}. " + "It would send the value to the database as plaintext. Use ==, <, <=, >, or >= " + "where the column's marker supports it.", + field=getattr(column, "key", None), + reason=name, + ) + + +def _column_name(column: Any) -> str: + table = getattr(getattr(column, "table", None), "name", "?") + return f"{table}.{getattr(column, 'name', '?')}" diff --git a/languages/python/tests/_compiled_cases.py b/languages/python/tests/_compiled_cases.py new file mode 100644 index 0000000000..f0f2714adb --- /dev/null +++ b/languages/python/tests/_compiled_cases.py @@ -0,0 +1,25 @@ +"""Compile ``_declaration_cases.py`` with pytest's assertion rewriting. + +The cases run through ``exec``, so pytest does not rewrite their ``assert`` +statements as it rewrites a test module's. A failure would then show a bare +``AssertionError`` with no values. This applies the same rewrite to the +parsed source first. ``rewrite_asserts`` is in pytest's private ``_pytest`` +package, so a pytest release can change it; a change fails both declaration +test modules at import. +""" + +import ast +import pathlib +from types import CodeType + +from _pytest.assertion.rewrite import rewrite_asserts + +CASES = pathlib.Path(__file__).with_name("_declaration_cases.py") + + +def compiled_cases(flags: int = 0) -> CodeType: + """The cases, with their asserts rewritten, compiled with ``flags``.""" + source = CASES.read_bytes() + tree = ast.parse(source, filename=str(CASES)) + rewrite_asserts(tree, source, str(CASES)) + return compile(tree, str(CASES), "exec", flags=flags, dont_inherit=True) diff --git a/languages/python/tests/_declaration_cases.py b/languages/python/tests/_declaration_cases.py new file mode 100644 index 0000000000..233665c99e --- /dev/null +++ b/languages/python/tests/_declaration_cases.py @@ -0,0 +1,959 @@ +"""The declaration tests, compiled twice: as written, and with +``from __future__ import annotations``. See ``test_declaration.py`` and +``test_declaration_future_annotations.py``. + +The classes the tests declare refer only to module-level names, because +under the future import a hint is resolved from the module's globals. +""" + +import dataclasses +import datetime +import decimal +import pickle +import re +import sys +import typing +import uuid +from typing import Annotated, ClassVar, Final, List, NamedTuple, Optional, Union + +import attrs +import pydantic +import pydantic.dataclasses +import pytest +from sqlalchemy import inspect +from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column, relationship +from sqlalchemy import ForeignKey + +import stackencrypt +from stackencrypt import ( + BigintEq, + Boolean, + DateEq, + DeclarationError, + DoubleEq, + Ignore, + IntegerEq, + JsonSearch, + NumericEq, + Passthrough, + RealEq, + SmallintEq, + Text, + TextEq, + TimestampEq, +) +from stackencrypt import _eql +from stackencrypt._declaration import _KIND_TYPES, declaration_of + +EXPECTED_FIELDS = [("email", TextEq), ("age", Passthrough)] + + +def planned(cls): + declaration = declaration_of(cls) + assert declaration is not None, "the class was not declared" + return [(field.name, field.marker) for field in declaration.fields] + + +def refusal(build): + with pytest.raises(DeclarationError) as raised: + build() + return raised.value + + +# The five class kinds, each with one TextEq, one Passthrough and one Ignore. + + +def test_a_dataclass_declares(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + note: Annotated[str, Ignore] = "" + + assert planned(User) == EXPECTED_FIELDS + assert declaration_of(User).table == "users" + + +def test_an_attrs_class_declares(): + @stackencrypt.model("users") + @attrs.define + class User: + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + note: Annotated[str, Ignore] = "" + + assert planned(User) == EXPECTED_FIELDS + + +def test_a_pydantic_model_declares(): + @stackencrypt.model("users") + class User(pydantic.BaseModel): + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + note: Annotated[str, Ignore] = "" + + assert planned(User) == EXPECTED_FIELDS + + +class UserTuple(NamedTuple): + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + note: Annotated[str, Ignore] = "" + + +def test_a_named_tuple_declares(): + declared = stackencrypt.model("users")(UserTuple) + + assert declared is UserTuple + assert planned(UserTuple) == EXPECTED_FIELDS + + +class Base(DeclarativeBase): + pass + + +class Post(Base): + __tablename__ = "posts" + id: Mapped[Annotated[int, Passthrough]] = mapped_column(primary_key=True) + user_id: Mapped[Annotated[int, Passthrough]] = mapped_column(ForeignKey("users.id")) + + +@stackencrypt.model("users") +class User(Base): + __tablename__ = "users" + id: Mapped[Annotated[int, Passthrough]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] + age: Mapped[Annotated[int, Passthrough]] + posts: Mapped[Annotated[List["Post"], Ignore]] = relationship("Post") + + +def test_a_sqlalchemy_class_declares_through_mapped(): + assert planned(User) == [("id", Passthrough)] + EXPECTED_FIELDS + + +def test_a_relationship_marked_ignore_is_not_in_the_plan(): + assert "posts" not in [name for name, _ in planned(User)] + + +def test_sqlalchemy_still_maps_every_column_it_was_given(): + assert set(User.__table__.columns.keys()) == {"id", "email", "age"} + + +# Rule A and its codes. + + +def test_a_field_without_a_marker_is_refused(): + def build(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + age: int + + error = refusal(build) + assert error.code == "declaration_no_marker" + assert error.field == "age" + assert "declaration_no_marker" in str(error) + assert "'age'" in str(error) + + +def test_annotated_metadata_from_another_library_is_not_a_marker(): + def build(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, "not a marker"] + + assert refusal(build).code == "declaration_no_marker" + + +def test_a_field_with_two_markers_is_refused(): + def build(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq, Text] + + error = refusal(build) + assert error.code == "declaration_two_markers" + assert error.field == "email" + assert "declaration_two_markers" in str(error) + assert "'email'" in str(error) + + +def test_ignore_with_another_marker_is_two_markers(): + def build(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq, Ignore] + + assert refusal(build).code == "declaration_two_markers" + + +def test_a_wrapper_inside_the_marker_annotated_is_refused(): + # A plain class, whose fields come from its hints as a SQLAlchemy class's + # do. On a mapped class SQLAlchemy refuses this order itself, with + # MappedAnnotationError, before the decorator runs. + def build(): + @stackencrypt.model("users") + class User: + email: Annotated[Mapped[str], TextEq] + + error = refusal(build) + assert error.code == "declaration_marker_not_outermost" + assert error.field == "email" + assert error.reason == "rule_a" + assert "Mapped[Annotated[str, TextEq]]" in str(error) + + +def test_the_remedy_names_the_inner_type_as_written(): + def build(): + @stackencrypt.model("users") + class User: + email: Annotated[Mapped[Optional[str]], TextEq] + + message = str(refusal(build)) + assert "Union" not in message + assert ( + "Mapped[Annotated[Optional[str], TextEq]]" in message + or "Mapped[Annotated[str | None, TextEq]]" in message + ) + + +class KeywordConstructor: + """A plain class whose fields come from its hints, as a SQLAlchemy + class's do, with a constructor that takes any keyword argument.""" + + def __init__(self, **kwargs): + vars(self).update(kwargs) + + +def test_a_collection_value_type_is_not_a_misplaced_wrapper(): + @stackencrypt.model("users") + class User(KeywordConstructor): + email: Annotated[str, TextEq] + tags: Annotated[List[str], Ignore] + + assert planned(User) == [("email", TextEq)] + + +def test_a_generic_value_type_from_another_module_is_not_a_misplaced_wrapper(): + # re.Pattern[str] is a one-argument generic, but it is a value type, not + # a wrapper such as Mapped. + @stackencrypt.model("users") + class User(KeywordConstructor): + email: Annotated[str, TextEq] + pattern: Annotated[re.Pattern[str], Ignore] + + assert planned(User) == [("email", TextEq)] + + +def test_a_dunder_annotation_is_not_a_field(): + # As on a SQLAlchemy class: __tablename__ is configuration, not a column, + # and the user cannot mark it. + @stackencrypt.model("users") + class User(KeywordConstructor): + __tablename__: str = "users" + __table_args__: tuple = () + email: Annotated[str, TextEq] + + assert planned(User) == [("email", TextEq)] + + +@pytest.mark.parametrize("kind", ["dataclass", "plain"]) +def test_a_marker_inside_a_list_or_final_is_refused(kind): + def build_list(): + class User: + tags: List[Annotated[str, TextEq]] + + if kind == "dataclass": + User = dataclasses.dataclass(User) + stackencrypt.model("users")(User) + + def build_final(): + class User: + email: Final[Annotated[str, TextEq]] + + if kind == "dataclass": + User = dataclasses.dataclass(User) + stackencrypt.model("users")(User) + + for build in (build_list, build_final): + error = refusal(build) + assert error.code == "declaration_marker_not_outermost" + assert error.reason == "rule_a" + + +def test_a_marker_inside_another_type_is_refused(): + def build(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Optional[Annotated[str, TextEq]] + + error = refusal(build) + assert error.code == "declaration_marker_not_outermost" + assert error.field == "email" + + +class MyText(TextEq): + """A user's subclass of a marker, which is not a marker.""" + + +def test_a_subclass_of_a_marker_is_not_a_marker(): + def build(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, MyText] + + assert refusal(build).code == "declaration_no_marker" + + +def test_an_unresolved_hint_is_refused_with_its_field(): + # "Undefined" is a forward reference that never resolves, as a + # relationship to a class written later does. + def build(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + posts: Annotated[List["Undefined"], Ignore] = dataclasses.field( # noqa: F821 + default_factory=list + ) + + error = refusal(build) + assert error.code == "declaration_hints_unresolved" + assert error.field == "posts" + assert "'posts'" in str(error) + assert isinstance(error.__cause__, NameError) + + +def build_with_a_bad_hint(kind): + if kind == "missing attribute": + + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + posts: Annotated[List["sys.Undefined"], Ignore] = dataclasses.field( + default_factory=list + ) + + else: + + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + posts: "List[" = dataclasses.field(default_factory=list) # noqa: F722 + + +@pytest.mark.parametrize( + "kind, cause", + [("missing attribute", AttributeError), ("malformed string", SyntaxError)], +) +def test_a_hint_that_fails_to_evaluate_is_refused_with_its_field(kind, cause): + error = refusal(lambda: build_with_a_bad_hint(kind)) + assert error.code == "declaration_hints_unresolved" + assert error.field == "posts" + assert isinstance(error.__cause__, cause) + + +def build_with_a_class_var_before_an_unresolved_hint(kind): + # A ClassVar hint written as a string resolves on a class, not as a + # function argument. Pydantic's own bases hold such hints. + if kind == "dataclass": + + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + count: "ClassVar[int]" = 0 + email: Annotated[str, TextEq] = "" + posts: Annotated[List["Undefined"], Ignore] = dataclasses.field( # noqa: F821 + default_factory=list + ) + + else: + + @stackencrypt.model("users") + class User(pydantic.BaseModel): + email: Annotated[str, TextEq] + posts: Annotated[List["Undefined"], Ignore] = [] # noqa: F821 + + +@pytest.mark.parametrize("kind", ["dataclass", "pydantic"]) +def test_an_unresolved_hint_after_a_class_var_is_named(kind): + error = refusal(lambda: build_with_a_class_var_before_an_unresolved_hint(kind)) + assert error.code == "declaration_hints_unresolved" + assert error.field == "posts" + + +class LaterBase(DeclarativeBase): + pass + + +def test_a_relationship_to_a_class_defined_later_needs_no_marker(credentials): + # Author is defined after Book, as a relationship target often is. A + # relationship is never a column, so it needs no marker, and its hint + # is not resolved. + @stackencrypt.model("books") + class Book(LaterBase): + __tablename__ = "books" + id: Mapped[Annotated[int, Passthrough]] = mapped_column(primary_key=True) + title: Mapped[Annotated[str, TextEq]] + authors: Mapped[List["Author"]] = relationship(back_populates="book") # noqa: F821 + + class Author(LaterBase): + __tablename__ = "authors" + id: Mapped[int] = mapped_column(primary_key=True) + book_id: Mapped[int] = mapped_column(ForeignKey("books.id")) + book: Mapped["Book"] = relationship(back_populates="authors") + + assert planned(Book) == [("id", Passthrough), ("title", TextEq)] + # SQLAlchemy still reads the hint: the relationship is a list of Author. + assert inspect(Book).relationships["authors"].uselist is True + stackencrypt.Cipher(Book) # the class is as the decorator left it + + +def test_a_relationship_to_one_object_needs_no_marker(): + @stackencrypt.model("comments") + class Comment(Base): + __tablename__ = "comments" + id: Mapped[Annotated[int, Passthrough]] = mapped_column(primary_key=True) + body: Mapped[Annotated[str, TextEq]] + user_id: Mapped[Annotated[int, Passthrough]] = mapped_column(ForeignKey("users.id")) + user: Mapped["User"] = relationship() + + assert planned(Comment) == [("id", Passthrough), ("body", TextEq), ("user_id", Passthrough)] + + +def test_a_sqlalchemy_column_with_no_marker_is_still_refused(): + def build(): + @stackencrypt.model("notes") + class Note(Base): + __tablename__ = "notes" + id: Mapped[Annotated[int, Passthrough]] = mapped_column(primary_key=True) + body: Mapped[str] + + error = refusal(build) + assert error.code == "declaration_no_marker" + assert error.field == "body" + + +def test_a_class_is_declared_once(): + def build(): + @stackencrypt.model("users") + @stackencrypt.model("accounts") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + + error = refusal(build) + assert error.code == "declaration_already_declared" + assert "'accounts'" in str(error) + + +def test_a_declaration_error_survives_pickle(): + error = DeclarationError("declaration_no_marker", "no marker", field="age", reason="rule_a") + + copy = pickle.loads(pickle.dumps(error)) + + assert type(copy) is DeclarationError + assert (copy.code, copy.message, copy.field, copy.reason) == ( + "declaration_no_marker", + "no marker", + "age", + "rule_a", + ) + assert str(copy) == str(error) == "declaration_no_marker: no marker" + + +def test_class_level_names_are_not_fields(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + registry: ClassVar[dict] = {} + + assert planned(User) == [("email", TextEq)] + + +def test_class_level_hints_are_not_fields_on_a_class_read_from_its_hints(): + # No library field list, so the fields come from the hints, as on a + # SQLAlchemy class. Each form the filter checks is here. + @stackencrypt.model("users") + class User(KeywordConstructor): + email: Annotated[str, TextEq] + registry: ClassVar[dict] = {} + token: dataclasses.InitVar[str] = "" + if sys.version_info >= (3, 10): + # Python 3.9 cannot resolve a bare ClassVar written as a string, + # as the future import writes it: get_type_hints raises. + bare: ClassVar = 0 + + assert planned(User) == [("email", TextEq)] + + +def test_a_bare_class_var_python_cannot_read_is_refused_with_advice(): + # Python 3.9 cannot read a bare ClassVar written as a string, as the + # future import writes it. Python 3.14 can. The test asks this Python. + class User(KeywordConstructor): + email: Annotated[str, TextEq] + count: ClassVar = 0 + + try: + typing.get_type_hints(User, include_extras=True) + except TypeError: + error = refusal(lambda: stackencrypt.model("users")(User)) + assert error.code == "declaration_hints_unresolved" + assert error.field == "count" + assert error.reason == "bare_class_var" + assert "ClassVar[int]" in str(error) + else: + assert planned(stackencrypt.model("users")(User)) == [("email", TextEq)] + + +class RefusedBase(DeclarativeBase): + pass + + +def build_a_mapped_class(kind): + if kind == "no marker": + + @stackencrypt.model("mapped_no_marker") + class MappedNoMarker(RefusedBase): + __tablename__ = "mapped_no_marker" + id: Mapped[Annotated[int, Passthrough]] = mapped_column(primary_key=True) + email: Mapped[str] + + else: + + @stackencrypt.model("mapped_two_markers") + class MappedTwoMarkers(RefusedBase): + __tablename__ = "mapped_two_markers" + id: Mapped[Annotated[int, Passthrough]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq, Passthrough]] + + +@pytest.mark.parametrize( + "kind, code", + [("no marker", "declaration_no_marker"), ("two markers", "declaration_two_markers")], +) +def test_rule_a_holds_inside_mapped(kind, code): + error = refusal(lambda: build_a_mapped_class(kind)) + assert error.code == code + assert error.field == "email" + + +def test_a_subclass_is_not_declared_until_it_is_decorated(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + + @dataclasses.dataclass + class Admin(User): + level: Annotated[int, Passthrough] = 0 + + assert declaration_of(Admin) is None + + +def test_model_needs_the_table_name(): + with pytest.raises(TypeError, match="table name"): + + @stackencrypt.model + class User: + email: Annotated[str, TextEq] + + +def test_model_refuses_an_empty_table_name(): + with pytest.raises(TypeError, match="table name"): + stackencrypt.model("") + + +def test_declaring_a_class_needs_no_credentials(monkeypatch): + for name in ("CS_WORKSPACE_CRN", "CS_CLIENT_ID", "CS_CLIENT_KEY", "CS_CLIENT_ACCESS_KEY"): + monkeypatch.delenv(name, raising=False) + + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + + assert planned(User) == [("email", TextEq)] + + +# The value type against the marker's kind. + + +@pytest.mark.parametrize( + "value_type, marker", + [ + (str, TextEq), + (int, SmallintEq), + (int, IntegerEq), + (int, BigintEq), + (float, RealEq), + (float, DoubleEq), + (bool, Boolean), + (decimal.Decimal, NumericEq), + (datetime.date, DateEq), + (datetime.datetime, TimestampEq), + ], +) +def test_each_kind_takes_its_one_python_type(value_type, marker): + User = dataclasses.make_dataclass("User", [("value", Annotated[value_type, marker])]) + + assert planned(stackencrypt.model("users")(User)) == [("value", marker)] + + +def test_every_producible_kind_but_json_has_a_python_type(): + # A new kind in the catalog fails here until the table names its type. + # JSON has no Python type in v1 on purpose (#1341, Out of Scope). + kinds = {marker.plaintext for marker in _eql.TYPES if marker.producible} + assert kinds - set(_KIND_TYPES) == {"json"} + + +def test_a_value_type_that_is_not_the_kinds_is_refused(): + def build(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[int, TextEq] + + error = refusal(build) + assert error.code == "declaration_type_mismatch" + assert error.field == "email" + assert error.reason == "string" + assert "'email'" in str(error) + assert "TextEq" in str(error) + assert "Annotated[str, TextEq]" in str(error) + + +@pytest.mark.parametrize( + "value_type, marker", + [ + (Optional[int], TextEq), + (Union[str, int], TextEq), + (Optional[Union[str, int]], TextEq), + (bool, IntegerEq), + (datetime.datetime, DateEq), + (int, DoubleEq), + ], +) +def test_a_value_type_must_be_exactly_the_kinds(value_type, marker): + User = dataclasses.make_dataclass("User", [("value", Annotated[value_type, marker])]) + + error = refusal(lambda: stackencrypt.model("users")(User)) + assert error.code == "declaration_type_mismatch" + + +def test_an_optional_field_is_nullable(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + city: Annotated[Optional[str], TextEq] + age: Annotated[Optional[int], Passthrough] + + fields = {f.name: (f.value_type, f.nullable) for f in declaration_of(User).fields} + assert fields == {"email": (str, False), "city": (str, True), "age": (int, True)} + + +@pytest.mark.skipif(sys.version_info < (3, 10), reason="`T | None` is Python 3.10 and later") +def test_a_union_with_none_is_nullable_too(): + User = dataclasses.make_dataclass("User", [("city", Annotated[eval("str | None"), TextEq])]) + + [field] = declaration_of(stackencrypt.model("users")(User)).fields + assert (field.value_type, field.nullable) == (str, True) + + +@pytest.mark.parametrize( + "value_type", + [str, int, float, bool, bytes, datetime.date, datetime.datetime, decimal.Decimal], +) +def test_a_passthrough_field_takes_the_eight_types(value_type): + User = dataclasses.make_dataclass("User", [("value", Annotated[value_type, Passthrough])]) + + assert planned(stackencrypt.model("users")(User)) == [("value", Passthrough)] + + +def test_a_passthrough_uuid_is_refused(): + def build(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + id: Annotated[uuid.UUID, Passthrough] + email: Annotated[str, TextEq] + + error = refusal(build) + assert error.code == "declaration_type_mismatch" + assert error.field == "id" + assert error.reason == "passthrough" + + +def test_a_json_field_has_no_python_type_yet(): + def build(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + profile: Annotated[str, JsonSearch] + + error = refusal(build) + assert error.code == "declaration_type_mismatch" + assert error.reason == "json" + + +def test_an_unproducible_marker_is_refused_with_the_catalog_reason(monkeypatch): + # The catalog has no unproducible type now, so Json stands in for one. + monkeypatch.setattr(_eql.Json, "producible", False) + monkeypatch.setattr(_eql.Json, "reason", "how it is sealed is not decided") + + def build(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + profile: Annotated[str, _eql.Json] + + error = refusal(build) + assert error.code == "declaration_unproducible" + assert error.field == "profile" + assert _eql.Json.reason in str(error) + + +# The constructor. + + +def test_the_decorator_below_dataclass_is_refused(): + def build(): + @dataclasses.dataclass + @stackencrypt.model("users") + class User: + email: Annotated[str, TextEq] + + error = refusal(build) + assert error.code == "declaration_not_outermost" + assert error.field == "email" + assert "above @dataclass" in str(error) + + +def test_the_decorator_below_attrs_is_refused(): + def build(): + @attrs.define + @stackencrypt.model("users") + class User: + email: Annotated[str, TextEq] + + assert refusal(build).code == "declaration_not_outermost" + + +def build_without_the_field_in_the_constructor(kind): + if kind == "init=False field": + + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] = dataclasses.field(init=False) + + elif kind == "init=False class": + + @stackencrypt.model("users") + @dataclasses.dataclass(init=False) + class User: + email: Annotated[str, TextEq] + + elif kind == "attrs private name": + + @stackencrypt.model("users") + @attrs.define + class User: + _email: Annotated[str, TextEq] + + elif kind == "pydantic dataclass init=False field": + + @stackencrypt.model("users") + @pydantic.dataclasses.dataclass + class User: + email: Annotated[str, TextEq] = dataclasses.field(init=False) + + elif kind == "pydantic alias": + + @stackencrypt.model("users") + class User(pydantic.BaseModel): + email: Annotated[str, TextEq] = pydantic.Field(alias="Email") + + else: + + @stackencrypt.model("users") + class User(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="allow") + email: Annotated[str, TextEq] = pydantic.Field(alias="Email") + + +@pytest.mark.parametrize( + "kind", + [ + "init=False field", + "init=False class", + "attrs private name", + "pydantic dataclass init=False field", + "pydantic alias", + "pydantic alias with extra=allow", + ], +) +def test_a_field_the_constructor_does_not_take_is_refused(kind): + error = refusal(lambda: build_without_the_field_in_the_constructor(kind)) + assert error.code == "declaration_constructor_missing_field" + assert "above @dataclass" not in str(error) + + +def test_a_pydantic_alias_is_accepted_when_the_name_is_taken_too(): + @stackencrypt.model("users") + class User(pydantic.BaseModel): + model_config = pydantic.ConfigDict(populate_by_name=True) + email: Annotated[str, TextEq] = pydantic.Field(alias="Email") + + assert planned(User) == [("email", TextEq)] + + +def test_a_pydantic_dataclass_alias_is_accepted_when_its_config_takes_the_name(): + # A Pydantic dataclass keeps its config in __pydantic_config__. + @stackencrypt.model("users") + @pydantic.dataclasses.dataclass(config=pydantic.ConfigDict(populate_by_name=True)) + class User: + email: Annotated[str, TextEq] = pydantic.Field(alias="Email") + + assert planned(User) == [("email", TextEq)] + + +def build_with_a_required_ignore(kind): + if kind == "dataclass": + + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + note: Annotated[str, Ignore] + + elif kind == "attrs": + + @stackencrypt.model("users") + @attrs.define + class User: + email: Annotated[str, TextEq] + note: Annotated[str, Ignore] + + elif kind == "attrs private name": + + @stackencrypt.model("users") + @attrs.define + class User: + email: Annotated[str, TextEq] + _note: Annotated[str, Ignore] + + elif kind == "pydantic alias": + + @stackencrypt.model("users") + class User(pydantic.BaseModel): + email: Annotated[str, TextEq] + note: Annotated[str, Ignore] = pydantic.Field(alias="Note") + + else: + + @stackencrypt.model("users") + class User(pydantic.BaseModel): + email: Annotated[str, TextEq] + note: Annotated[str, Ignore] + + +@pytest.mark.parametrize( + "kind, field", + [ + ("dataclass", "note"), + ("attrs", "note"), + ("attrs private name", "_note"), + ("pydantic", "note"), + ("pydantic alias", "note"), + ], +) +def test_a_required_ignore_field_is_refused(kind, field): + error = refusal(lambda: build_with_a_required_ignore(kind)) + assert error.code == "declaration_ignore_required" + assert error.field == field + assert "default" in str(error) + + +def test_an_ignore_field_the_constructor_does_not_name_is_omittable(): + @stackencrypt.model("users") + class User(KeywordConstructor): + email: Annotated[str, TextEq] + note: Annotated[str, Ignore] + + assert planned(User) == [("email", TextEq)] + + +# The engine's own check of the plan. + + +def test_a_plan_the_engine_refuses_is_a_declaration_error(): + # Every field is Ignore, so the plan has no field, and the engine refuses + # an empty plan. + def build(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + note: Annotated[str, Ignore] = "" + + error = refusal(build) + assert error.code == "stack_encrypt_dynamic_plan" + assert error.reason == "no_fields" + + +class DataclassBase(MappedAsDataclass, DeclarativeBase): + pass + + +def test_a_relationship_of_a_mapped_dataclass_needs_no_marker(credentials): + # dataclasses.fields lists the relationship, though it is never a column. + @stackencrypt.model("shelves") + class Shelf(DataclassBase): + __tablename__ = "shelves" + id: Mapped[Annotated[int, Passthrough]] = mapped_column(primary_key=True) + label: Mapped[Annotated[str, TextEq]] + boxes: Mapped[List["Crate"]] = relationship(default_factory=list) + + @stackencrypt.model("cupboards") + class Cupboard(DataclassBase): + __tablename__ = "cupboards" + id: Mapped[Annotated[int, Passthrough]] = mapped_column(primary_key=True) + label: Mapped[Annotated[str, TextEq]] + # init=False: under Annotated, SQLAlchemy cannot tell the collection + # type, so it takes no default_factory. + boxes: Mapped[Annotated[List["Crate"], Ignore]] = relationship(init=False) + + class Crate(DataclassBase): + __tablename__ = "crates" + id: Mapped[int] = mapped_column(primary_key=True, init=False) + shelf_id: Mapped[Optional[int]] = mapped_column(ForeignKey("shelves.id"), default=None) + cupboard_id: Mapped[Optional[int]] = mapped_column(ForeignKey("cupboards.id"), default=None) + + assert planned(Shelf) == [("id", Passthrough), ("label", TextEq)] + assert planned(Cupboard) == [("id", Passthrough), ("label", TextEq)] + + +def test_a_field_named_like_a_module_its_hint_uses_resolves_from_the_module(): + # The class attribute `datetime` is the mapped column. The hint names + # the module, as get_type_hints(cls) resolves it. A relationship makes + # the hints resolve one at a time. + @stackencrypt.model("diary_days") + class Day(Base): + __tablename__ = "diary_days" + id: Mapped[Annotated[int, Passthrough]] = mapped_column(primary_key=True) + datetime: Mapped[Annotated[datetime.date, Passthrough]] + user_id: Mapped[Annotated[int, Passthrough]] = mapped_column(ForeignKey("users.id")) + user: Mapped["User"] = relationship() + + assert planned(Day) == [("id", Passthrough), ("datetime", Passthrough), ("user_id", Passthrough)] diff --git a/languages/python/tests/_first_call.py b/languages/python/tests/_first_call.py new file mode 100644 index 0000000000..891f5887b0 --- /dev/null +++ b/languages/python/tests/_first_call.py @@ -0,0 +1,19 @@ +"""Child process of `test_concurrency_live.py`: the threads make the first +call of this process together. Prints the counts as JSON.""" + +import json + +from _sync_contract import User, encrypt_from_threads + +from stackencrypt import Cipher, _engine + +assert _engine.client_builds() == 0 +encrypt_from_threads(Cipher(User), per_thread=1) +print( + json.dumps( + { + "builds": _engine.client_builds(), + "keyset_loads": _engine.requests().get("load-keyset", 0), + } + ) +) diff --git a/languages/python/tests/_fork.py b/languages/python/tests/_fork.py new file mode 100644 index 0000000000..3d0b622708 --- /dev/null +++ b/languages/python/tests/_fork.py @@ -0,0 +1,68 @@ +"""Child process of `test_concurrency_live.py`: a fork, and a call in the +forked child. Prints what the child did as JSON. + +`after` forks after the first call, so the parent's client exists: the +child must be refused, with no wait. `before` forks before any call, so no +client exists: the child must build its own and work. `during` forks while +another thread is building the client: the child holds a copy of the build +lock that no thread will release, and must be refused, not wait on it. + +The child's result comes back through a pipe. The parent waits at most 30 +seconds, so a child that never returns shows as "hang" and does not stop +the test run. +""" + +import json +import os +import sys +import threading +import time + +from _sync_contract import User, user + +from stackencrypt import Cipher, StashError, _engine + +mode = sys.argv[1] +cipher = Cipher(User) +if mode == "after": + cipher.encrypt([user(1)]) + assert _engine.client_builds() == 1 +if mode == "during": + builder = threading.Thread(target=cipher.encrypt, args=([user(1)],)) + builder.start() + time.sleep(0.05) + # The build loads the keyset over the network, which takes far longer + # than 50 ms. A build already done would make this the `after` case. + if _engine.client_builds() != 0 or not builder.is_alive(): + print(json.dumps({"child": "the build finished before the fork"})) + raise SystemExit(0) + +read_end, write_end = os.pipe() +pid = os.fork() +if pid == 0: + os.close(read_end) + try: + rows = cipher.encrypt([user(2)]) + opened = cipher.decrypt(rows) + result = {"child": "ok" if opened == [user(2)] else "wrong"} + except StashError as error: + result = {"child": error.code} + except BaseException as error: # noqa: BLE001 Reported to the parent. + result = {"child": f"error: {type(error).__name__}"} + os.write(write_end, json.dumps(result).encode()) + os._exit(0) + +os.close(write_end) +deadline = time.monotonic() + 30 +while time.monotonic() < deadline: + done, status = os.waitpid(pid, os.WNOHANG) + if done: + break + time.sleep(0.05) +else: + os.kill(pid, 9) + os.waitpid(pid, 0) + print(json.dumps({"child": "hang"})) + raise SystemExit(0) + +print(os.read(read_end, 4096).decode() or json.dumps({"child": f"no result, status {status}"})) diff --git a/languages/python/tests/_interrupt.py b/languages/python/tests/_interrupt.py new file mode 100644 index 0000000000..705c7f78d9 --- /dev/null +++ b/languages/python/tests/_interrupt.py @@ -0,0 +1,68 @@ +"""Child process of `test_concurrency_live.py`: SIGINT during a long +`encrypt`. Prints what happened as JSON. + +A reference time is measured first, as the gate test knows its call takes +one second. It is the fastest of three calls, so a slow one cannot raise +it. The call starts at 5000 values and doubles until the reference is long +enough to send a signal into. The signal is sent a quarter of the way into +one more call. A call cut short would raise at that quarter. A call that +waits raises after it returns, near the reference time. +""" + +import json +import time + +from _sync_contract import User, send_sigint_later, user, wait_for_the_interrupt + +from stackencrypt import Cipher + +LONG_ENOUGH = 0.4 +MOST_VALUES = 80_000 + + +def timed(call): + started = time.monotonic() + call() + return time.monotonic() - started + + +cipher = Cipher(User) +# Load the keyset first, so the timed calls are the values alone. +cipher.encrypt([user(0)]) + +values = 5000 +while True: + models = [user(n) for n in range(values)] + reference = min(timed(lambda: cipher.encrypt(models)) for _ in range(3)) + if reference >= LONG_ENOUGH or values >= MOST_VALUES: + break + values *= 2 + +interrupts = 0 +arrived = None +rows = None +timer = send_sigint_later(reference / 4) +started = time.monotonic() +try: + rows = cipher.encrypt(models) + wait_for_the_interrupt() +except KeyboardInterrupt: + interrupts += 1 + arrived = time.monotonic() - started +timer.join() + +# Python checks for signals many times in the code below. A second +# interrupt would raise in it, and the process would exit with an error. +after = cipher.encrypt([user(1)]) +print( + json.dumps( + { + "values": values, + "reference": reference, + "arrived": arrived, + "interrupts": interrupts, + "rows": None if rows is None else len(rows), + "after": len(after), + } + ) +) diff --git a/languages/python/tests/_live.py b/languages/python/tests/_live.py new file mode 100644 index 0000000000..a58dcac93f --- /dev/null +++ b/languages/python/tests/_live.py @@ -0,0 +1,67 @@ +"""What every live test file shares: the credential and database checks, and +a runner for a child Python process. Not a test file: pytest collects +nothing here.""" + +import json +import os +import subprocess +import sys +from typing import Any, Dict, List, Optional + +import pytest + +CREDENTIALS = ("CS_CLIENT_ACCESS_KEY", "CS_WORKSPACE_CRN", "CS_CLIENT_ID", "CS_CLIENT_KEY") +DATABASE_URL = os.environ.get("EQL_TEST_DATABASE_URL", "") + +# A live test file sets `pytestmark = LIVE`. Without the credentials, every +# test in it skips, and `mise run test:live` fails on the skip. +LIVE = [ + pytest.mark.live, + pytest.mark.skipif( + not all(os.environ.get(name) for name in CREDENTIALS), + reason="needs CS_CLIENT_ACCESS_KEY, CS_WORKSPACE_CRN, CS_CLIENT_ID and CS_CLIENT_KEY", + ), +] + +needs_database = pytest.mark.skipif(not DATABASE_URL, reason="needs EQL_TEST_DATABASE_URL") + + +def sqlalchemy_url(**query: str) -> Any: + """``EQL_TEST_DATABASE_URL`` as a SQLAlchemy URL for psycopg 3, with + ``query`` added as connection parameters. + + The variable can be a ``postgresql://`` URL or a libpq keyword string + (``host=... port=...``), as CI sets it. psycopg reads both, and this + turns either one into the URL that SQLAlchemy needs. + """ + from psycopg.conninfo import conninfo_to_dict + from sqlalchemy.engine import URL + + params = {name: str(value) for name, value in conninfo_to_dict(DATABASE_URL).items()} + port = params.pop("port", None) + return URL.create( + "postgresql+psycopg", + username=params.pop("user", None), + password=params.pop("password", None), + host=params.pop("host", None), + port=int(port) if port else None, + database=params.pop("dbname", None), + query={**params, **query}, + ) + + +def run_python(args: List[str], env: Optional[Dict[str, str]] = None) -> Any: + """Run Python with ``args`` in a new process, with this one's + environment and ``env`` on top. Fails unless it exits with 0. Returns + the JSON it printed last.""" + done = subprocess.run( + [sys.executable, *args], + env={**os.environ, **(env or {})}, + capture_output=True, + text=True, + timeout=120, + ) + assert done.returncode == 0, done.stderr + lines = done.stdout.strip().splitlines() + assert lines, f"the child printed nothing. Its stderr:\n{done.stderr}" + return json.loads(lines[-1]) diff --git a/languages/python/tests/_readme.py b/languages/python/tests/_readme.py new file mode 100644 index 0000000000..02ac1e2305 --- /dev/null +++ b/languages/python/tests/_readme.py @@ -0,0 +1,111 @@ +"""The README's fenced code blocks, for `test_readme.py` and +`test_readme_live.py`. Not a test file: pytest collects nothing here.""" + +import ast +import builtins +import pathlib +import re +import textwrap +from typing import List, Set + +README = pathlib.Path(__file__).resolve().parents[1] / "README.md" + +# A fence may be indented, as in a list item, and may have words after its +# language, such as a title. The closing fence has the same indent. +_FENCE = re.compile(r"^( *)```(\w*)[^\n]*\n(.*?)^\1```[ \t]*$", re.MULTILINE | re.DOTALL) + +# Every line that could open or close a fence, for the check that none is +# missed: a backtick or tilde fence, indented or inside a blockquote. The +# parser reads only the first kind, so any other fails that check. +_FENCE_LINE = re.compile(r"^[ \t>]*(```|~~~)", re.MULTILINE) + +# Run after the README's Python blocks: print what the equality query found. +CHECK = """ +import json as _json +print(_json.dumps([dataclasses.asdict(user) for user in users])) +""" + + +def text() -> str: + return README.read_text(encoding="utf-8") + + +def fence_lines() -> int: + return len(_FENCE_LINE.findall(text())) + + +def all_blocks() -> List[str]: + return [lang for _, lang, _ in _FENCE.findall(text())] + + +def blocks(language: str) -> List[str]: + """Every block of ``language`` in the README, in order, with the indent + of a list item removed.""" + return [ + textwrap.dedent(body) for _, lang, body in _FENCE.findall(text()) if lang == language + ] + + +def program() -> str: + """The README's Python blocks as one program, in order. A reader runs + them in one session, so each block may use the names of those before.""" + return "\n\n".join(blocks("python")) + + +class _TopLevel(ast.NodeVisitor): + """The names a program binds at its top level. A function, class, + lambda, or comprehension has a scope of its own, so its names are + skipped.""" + + def __init__(self) -> None: + self.bound: Set[str] = set() + + def visit_Name(self, node: ast.Name) -> None: + if isinstance(node.ctx, ast.Store): + self.bound.add(node.id) + + def visit_alias(self, node: ast.alias) -> None: + self.bound.add((node.asname or node.name).split(".")[0]) + + def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None: + # `except E as name` binds `name`, which is a str, not an ast.Name. + if node.name: + self.bound.add(node.name) + self.generic_visit(node) + + def _own_scope(self, node: ast.AST) -> None: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + self.bound.add(node.name) + + def _comprehension(self, node: ast.AST) -> None: + # A comprehension's own variables stay inside it, but a walrus + # (`:=`) in it binds its target in the enclosing scope. + for inner in ast.walk(node): + if isinstance(inner, ast.NamedExpr) and isinstance(inner.target, ast.Name): + self.bound.add(inner.target.id) + + visit_FunctionDef = visit_AsyncFunctionDef = visit_ClassDef = _own_scope + visit_Lambda = _own_scope + visit_ListComp = visit_SetComp = visit_DictComp = _comprehension + visit_GeneratorExp = _comprehension + + +def _top_level(source: str) -> _TopLevel: + names = _TopLevel() + names.visit(ast.parse(source)) + return names + + +def bound_names(source: str) -> Set[str]: + """The names ``source`` assigns or imports at its top level.""" + return _top_level(source).bound + + +def check_names() -> Set[str]: + """The names CHECK reads from the README's program: those it reads, + less those it binds itself and Python's built-in names.""" + tree = ast.parse(CHECK) + read = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load)} + bound = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store)} + bound |= {(a.asname or a.name) for n in ast.walk(tree) if isinstance(n, ast.Import) for a in n.names} + return read - bound - set(dir(builtins)) diff --git a/languages/python/tests/_sync_contract.py b/languages/python/tests/_sync_contract.py new file mode 100644 index 0000000000..6d3564d75d --- /dev/null +++ b/languages/python/tests/_sync_contract.py @@ -0,0 +1,103 @@ +"""The parts of the sync contract tests that more than one file uses: the +model, the threads, and the interrupt. + +The users are `tests/test_concurrency_live.py`, the deterministic copy in +`tests_deterministic/test_concurrency.py`, the two child scripts +`_first_call.py` and `_interrupt.py`, and the gate test +`test_gate_keyboard_interrupt.py`. Not a test file: pytest collects +nothing here. +""" + +import dataclasses +import os +import signal +import threading +import time +import traceback +from typing import Annotated, Dict, List + +import stackencrypt +from stackencrypt import Cipher, Passthrough, TextEq + +THREADS = 8 +PER_THREAD = 100 + + +@stackencrypt.model("users") +@dataclasses.dataclass +class User: + id: Annotated[int, Passthrough] + email: Annotated[str, TextEq] + + +def user(n: int) -> User: + return User(id=n, email=f"user{n}@example.com") + + +def encrypt_from_threads(cipher: Cipher, per_thread: int) -> List[Dict[str, object]]: + """Release ``THREADS`` threads at once, each to encrypt ``per_thread`` + users one call at a time. Returns every row, in user order. Raises if + any call failed.""" + sealed: List[List[Dict[str, object]]] = [[] for _ in range(THREADS)] + failures: List[str] = [] + # A timeout, so one thread that never arrives fails the test, not hangs it. + start = threading.Barrier(THREADS, timeout=30) + + def encrypt_many(t: int) -> None: + try: + start.wait() + for n in range(per_thread): + sealed[t].extend(cipher.encrypt([user(t * per_thread + n)])) + except Exception: + failures.append(f"thread {t}: {traceback.format_exc()}") + + threads = [threading.Thread(target=encrypt_many, args=(t,)) for t in range(THREADS)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + assert failures == [], failures + return [row for rows in sealed for row in rows] + + +def decrypt_from_threads(cipher: Cipher, rows: List[Dict[str, object]]) -> List[User]: + """Release ``THREADS`` threads at once, each to decrypt its own slice of + ``rows`` one call at a time. Returns every user, in row order. Raises if + any call failed.""" + per_thread = -(-len(rows) // THREADS) + opened: List[List[User]] = [[] for _ in range(THREADS)] + failures: List[str] = [] + start = threading.Barrier(THREADS, timeout=30) + + def decrypt_many(t: int) -> None: + try: + start.wait() + for row in rows[t * per_thread : (t + 1) * per_thread]: + opened[t].extend(cipher.decrypt([row])) + except Exception: + failures.append(f"thread {t}: {traceback.format_exc()}") + + threads = [threading.Thread(target=decrypt_many, args=(t,)) for t in range(THREADS)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + assert failures == [], failures + return [model for models in opened for model in models] + + +def send_sigint_later(seconds: float) -> threading.Timer: + """Send SIGINT to this process after ``seconds``.""" + timer = threading.Timer(seconds, os.kill, (os.getpid(), signal.SIGINT)) + timer.start() + return timer + + +def wait_for_the_interrupt() -> None: + """Python raises a pending interrupt at its next check after a call. The + loop gives it that check, and ends if it never comes.""" + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + # A short sleep still returns to Python, which then checks for the + # signal, and it does not keep a core busy. + time.sleep(0.01) diff --git a/languages/python/tests/conftest.py b/languages/python/tests/conftest.py new file mode 100644 index 0000000000..70a4e936d8 --- /dev/null +++ b/languages/python/tests/conftest.py @@ -0,0 +1,61 @@ +"""The fixtures every offline test module uses. + +One copy, so that a variable added here is cleared for every module. A +variable left set in one module could let its tests reach the network. +""" + +import pytest + +from stackencrypt import _engine + +ACCESS_KEY = "CSAKtestKeyId.testKeySecret" +WORKSPACE_CRN = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY" + +# Every variable the engine client reads: the credentials, the client key, +# and the service hosts. +CS_VARIABLES = ( + "CS_CLIENT_ACCESS_KEY", + "CS_WORKSPACE_CRN", + "CS_CLIENT_ID", + "CS_CLIENT_KEY", + "CS_CTS_HOST", + "CS_ZEROKMS_HOST", + "CS_VITUR_HOST", +) + + +@pytest.fixture +def no_credentials(monkeypatch, tmp_path): + """No CipherStash variable in the environment, and an empty profile store.""" + for name in CS_VARIABLES: + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("CS_CONFIG_PATH", str(tmp_path / "no-profile")) + + +@pytest.fixture +def credentials(monkeypatch, no_credentials): + """Credentials that parse, so ``Cipher`` builds, and no client key. A + call that gets past every check fails building the engine client, with + no request. + + The engine client is shared by the whole process. Once a live test has + built it, a call here would reach ZeroKMS instead of failing, so the + test is skipped. ``mise run test`` and ``-m live`` run the two kinds of + test in separate processes, and skip nothing. + """ + if _engine.client_builds() > 0: + pytest.skip( + "a live test built the shared engine client earlier in this " + "process; run the offline tests in a process of their own" + ) + monkeypatch.setenv("CS_CLIENT_ACCESS_KEY", ACCESS_KEY) + monkeypatch.setenv("CS_WORKSPACE_CRN", WORKSPACE_CRN) + + +@pytest.fixture +def untouched(): + """Fails the test if the shared client was built or a request was sent.""" + builds, requests = _engine.client_builds(), _engine.request_count() + yield + assert _engine.client_builds() == builds + assert _engine.request_count() == requests diff --git a/languages/python/tests/test_cipher.py b/languages/python/tests/test_cipher.py new file mode 100644 index 0000000000..a3b619136d --- /dev/null +++ b/languages/python/tests/test_cipher.py @@ -0,0 +1,248 @@ +"""``Cipher(cls)``: the class checks and the credentials. No request is made.""" + +import dataclasses +import math +from typing import Annotated + +import pytest + +import stackencrypt +from stackencrypt import ( + AuthenticationError, + Cipher, + DeclarationError, + Passthrough, + StashError, + TextEq, +) + +from conftest import ACCESS_KEY, WORKSPACE_CRN + +pytestmark = pytest.mark.usefixtures("no_credentials") + + +def declared(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + + return User + + +def refusal(build): + with pytest.raises(StashError) as raised: + build() + return raised.value + + +def test_a_cipher_is_built_for_a_declared_class(credentials): + User = declared() + + users = Cipher(User) + + assert users.declaration.table == "users" + assert [f.name for f in users.declaration.fields] == ["email", "age"] + + +def test_an_undecorated_class_is_refused(): + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + + error = refusal(lambda: Cipher(User)) + assert isinstance(error, DeclarationError) + assert error.code == "declaration_not_decorated" + + +def test_a_subclass_of_a_declared_class_is_refused(): + User = declared() + + @dataclasses.dataclass + class Admin(User): + level: Annotated[int, Passthrough] = 0 + + error = refusal(lambda: Cipher(Admin)) + assert error.code == "declaration_not_decorated" + assert "subclass of 'User'" in str(error) + + +@pytest.mark.parametrize("value", ["users", None, 42]) +def test_a_value_that_is_not_a_class_is_refused(value): + error = refusal(lambda: Cipher(value)) + assert error.code == "declaration_not_decorated" + assert repr(value) in str(error) + + +def test_an_instance_is_refused(): + User = declared() + + error = refusal(lambda: Cipher(User(email="a", age=1))) + assert error.code == "declaration_not_decorated" + + +def test_a_slotted_instance_is_refused(): + class Slotted: + __slots__ = () + + assert refusal(lambda: Cipher(Slotted())).code == "declaration_not_decorated" + + +def test_a_default_that_is_not_equal_to_itself_is_still_unchanged(credentials): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + score: Annotated[float, Passthrough] = math.nan + + assert Cipher(User).declaration.table == "users" + + +def test_the_class_checks_come_before_the_credentials(): + # No credentials are set, and the class error is the one raised. + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + + assert refusal(lambda: Cipher(User)).code == "declaration_not_decorated" + + +def test_an_annotation_added_after_decoration_is_refused(credentials): + User = declared() + User.__annotations__["nickname"] = Annotated[str, TextEq] + + error = refusal(lambda: Cipher(User)) + assert error.code == "declaration_class_changed" + assert error.reason == "fields" + assert error.field == "nickname" + + +def test_a_replaced_repr_is_refused(credentials): + User = declared() + User.__repr__ = lambda self: self.email + + error = refusal(lambda: Cipher(User)) + assert error.code == "declaration_class_changed" + assert error.reason == "methods" + assert "__repr__ replaced" in str(error) + + +@pytest.mark.parametrize("change", ["changed", "removed"]) +def test_a_changed_or_removed_hint_is_refused(credentials, change): + User = declared() + if change == "changed": + User.__annotations__["age"] = Annotated[str, Passthrough] + else: + del User.__annotations__["age"] + + error = refusal(lambda: Cipher(User)) + assert error.code == "declaration_class_changed" + assert error.reason == "fields" + assert error.field == "age" + + +@pytest.mark.parametrize("name", ["__str__", "__rich_repr__"]) +def test_each_replaced_method_is_refused(credentials, name): + User = declared() + setattr(User, name, lambda self: self.email) + + error = refusal(lambda: Cipher(User)) + assert error.code == "declaration_class_changed" + assert error.reason == "methods" + assert f"{name} replaced" in str(error) + + +def test_a_redact_false_class_may_replace_its_printing_methods(credentials): + # The README says Cipher checks the printing methods only when the + # decorator replaced them. + @stackencrypt.model("users", redact=False) + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + + User.__repr__ = lambda self: "replaced" + + Cipher(User) + + +def test_a_hint_that_no_longer_resolves_names_the_name(credentials): + User = declared() + User.__annotations__["posts"] = "List[Undefined]" + + error = refusal(lambda: Cipher(User)) + assert error.code == "declaration_class_changed" + assert error.reason == "fields" + assert "Undefined" in str(error) or "List" in str(error) + + +def test_a_replaced_constructor_is_refused(credentials): + User = declared() + + def __init__(self, email): + self.email = email + + User.__init__ = __init__ + + error = refusal(lambda: Cipher(User)) + assert error.code == "declaration_class_changed" + assert error.reason == "constructor" + + +def test_no_credentials_is_an_authentication_error_from_the_engine(): + User = declared() + + error = refusal(lambda: Cipher(User)) + assert isinstance(error, AuthenticationError) + assert error.code == "stack_auth_not_authenticated" + assert "CS_CLIENT_ACCESS_KEY" in error.message + assert "AutoStrategy" not in error.message + + +def test_a_bad_cts_host_names_the_variable(monkeypatch, credentials): + monkeypatch.setenv("CS_CTS_HOST", "not a url") + User = declared() + + error = refusal(lambda: Cipher(User)) + assert isinstance(error, AuthenticationError) + assert "CS_CTS_HOST" in error.message + + +def test_a_corrupt_profile_names_the_remedy(monkeypatch, tmp_path): + # No access key, and a profile whose token file is not JSON. The layout + # is the profile store's: a current_workspace file, and the workspace's + # own directory with auth.json in it. + profile = tmp_path / "profile" + workspace = profile / "workspaces" / "ZVATKW3VHMFG27DY" + workspace.mkdir(parents=True) + (profile / "current_workspace").write_text("ZVATKW3VHMFG27DY") + (workspace / "auth.json").write_text("{not json") + monkeypatch.setenv("CS_CONFIG_PATH", str(profile)) + User = declared() + + error = refusal(lambda: Cipher(User)) + assert isinstance(error, AuthenticationError) + assert error.code != "stack_auth_not_authenticated" + assert "CS_CONFIG_PATH" in error.message + assert "stash auth login" in error.message + + +def test_a_missing_workspace_crn_names_the_variable(monkeypatch): + monkeypatch.setenv("CS_CLIENT_ACCESS_KEY", ACCESS_KEY) + User = declared() + + error = refusal(lambda: Cipher(User)) + assert error.code == "stack_auth_missing_workspace_crn" + assert "CS_WORKSPACE_CRN" in error.message + assert "AutoStrategy" not in error.message + + +def test_a_malformed_access_key_is_an_authentication_error(monkeypatch): + monkeypatch.setenv("CS_CLIENT_ACCESS_KEY", "not-an-access-key") + monkeypatch.setenv("CS_WORKSPACE_CRN", WORKSPACE_CRN) + User = declared() + + error = refusal(lambda: Cipher(User)) + assert isinstance(error, AuthenticationError) + assert error.code.startswith("stack_auth_") + assert "not-an-access-key" not in str(error) diff --git a/languages/python/tests/test_concurrency_live.py b/languages/python/tests/test_concurrency_live.py new file mode 100644 index 0000000000..70ee23246e --- /dev/null +++ b/languages/python/tests/test_concurrency_live.py @@ -0,0 +1,86 @@ +"""The sync contract from the spec in #1341, held against ZeroKMS: one engine +client per process, shared by every thread, and a ``KeyboardInterrupt`` +that waits for the call. + +These tests need the four CipherStash credentials. Without them every test +here skips. The first-call test and the interrupt test each run a script in +a new Python process: the client of this one is already built, and the +interrupt is for the whole process. +""" + +import pathlib +import sys + +import pytest +from _live import LIVE, run_python +from _sync_contract import ( + PER_THREAD, + THREADS, + User, + decrypt_from_threads, + encrypt_from_threads, + user, +) + +from stackencrypt import Cipher + +pytestmark = LIVE + +HERE = pathlib.Path(__file__).parent + + +def test_threads_making_their_first_call_at_once_build_one_client(): + assert run_python([str(HERE / "_first_call.py")]) == {"builds": 1, "keyset_loads": 1} + + +def test_threads_encrypt_at_once_and_every_row_opens(): + cipher = Cipher(User) + + rows = encrypt_from_threads(cipher, PER_THREAD) + + assert cipher.decrypt(rows) == [user(n) for n in range(THREADS * PER_THREAD)] + + +def test_threads_decrypt_at_once_and_every_row_opens(): + cipher = Cipher(User) + rows = encrypt_from_threads(cipher, PER_THREAD) + + assert decrypt_from_threads(cipher, rows) == [user(n) for n in range(THREADS * PER_THREAD)] + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows has no fork") +@pytest.mark.parametrize( + "mode, child", + [ + # The parent's client cannot work in the child, and a new runtime + # there can block. The child is refused at once, with what to do. + ("after", "stack_forked"), + # No client existed at the fork, so the child builds its own. + ("before", "ok"), + # Another thread was building the client at the fork. The child's + # copy of the build lock is never released, so the child is refused + # rather than wait on it. + ("during", "stack_forked"), + ], +) +def test_a_forked_child_is_refused_only_when_the_client_was_built_before_the_fork( + mode, child +): + assert run_python([str(HERE / "_fork.py"), mode]) == {"child": child} + + +@pytest.mark.skipif(sys.platform == "win32", reason="SIGINT is sent with os.kill") +def test_an_interrupt_during_encrypt_waits_for_it_and_the_cipher_still_works(): + result = run_python([str(HERE / "_interrupt.py")]) + + # The script grows the call until it takes 0.4 seconds, up to 80,000 + # values. Below 0.2, there is too little time to send a signal into it. + assert result["reference"] >= 0.2, result + assert result["interrupts"] == 1, result + # A call cut short raises at a quarter of the reference. A call that + # waits raises after it returns, near the reference time. + assert result["arrived"] >= result["reference"] / 2, result + # All the rows, or the interrupt came before the assignment and left + # nothing: never part of a result. + assert result["rows"] in (None, result["values"]), result + assert result["after"] == 1, result diff --git a/languages/python/tests/test_declaration.py b/languages/python/tests/test_declaration.py new file mode 100644 index 0000000000..3506cb3ce2 --- /dev/null +++ b/languages/python/tests/test_declaration.py @@ -0,0 +1,10 @@ +"""The declaration tests, with annotations evaluated as written. + +The tests themselves are in ``_declaration_cases.py``, compiled here without +``from __future__ import annotations``, with their asserts rewritten as +pytest rewrites a test module's (``_compiled_cases.py``). +""" + +from _compiled_cases import compiled_cases + +exec(compiled_cases(), globals()) diff --git a/languages/python/tests/test_declaration_future_annotations.py b/languages/python/tests/test_declaration_future_annotations.py new file mode 100644 index 0000000000..57dd044038 --- /dev/null +++ b/languages/python/tests/test_declaration_future_annotations.py @@ -0,0 +1,12 @@ +"""The declaration tests, compiled with ``from __future__ import annotations``. + +Under that import every annotation is a string until the decorator resolves +it, which is the case a real model module with the import is in. The asserts +are rewritten as pytest rewrites a test module's (``_compiled_cases.py``). +""" + +import __future__ + +from _compiled_cases import compiled_cases + +exec(compiled_cases(__future__.annotations.compiler_flag), globals()) diff --git a/languages/python/tests/test_decrypt.py b/languages/python/tests/test_decrypt.py new file mode 100644 index 0000000000..8728cae33c --- /dev/null +++ b/languages/python/tests/test_decrypt.py @@ -0,0 +1,242 @@ +"""``Cipher.decrypt`` before any request: the row checks and the empty list. + +Every row is checked before the shared client is built: its shape, its +columns, each EQL value's JSON and the table and column it names, and each +passthrough value's type. So these tests need fake credentials only, and +make no request. The live round trip is in ``test_encrypt_live.py``. +""" + +import dataclasses +import datetime +import json +import uuid +from typing import Annotated + +import pytest + +import stackencrypt +from stackencrypt import ( + Cipher, + DecryptError, + InvalidValueError, + Passthrough, + StashError, + TextEq, +) + +# The `i` of a value stored under users.email, and the rest of an EQL value +# left out: the checks before any request read only `i`. +STORED = json.dumps({"i": {"t": "users", "c": "email"}, "v": 2}) + + +pytestmark = pytest.mark.usefixtures("credentials") + + +@stackencrypt.model("users") +@dataclasses.dataclass +class User: + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + + +def refused(rows, error=DecryptError): + with pytest.raises(error) as raised: + Cipher(User).decrypt(rows) + return raised.value + + +def test_an_empty_list_is_an_empty_list_with_no_request(untouched): + assert Cipher(User).decrypt([]) == [] + + +def test_a_decrypt_error_is_a_stash_error(): + assert issubclass(DecryptError, StashError) + + +def test_a_tuple_row_is_the_wrong_shape_and_names_dict_row(untouched): + error = refused([(STORED, 3)]) + assert error.code == "decrypt_row_shape" + assert "dict_row" in error.message + + +def test_one_row_on_its_own_is_refused_as_one_row(untouched): + # As from cursor.fetchone(): list() of a dict is its column names. + error = refused({"email": STORED, "age": 3}) + assert error.code == "decrypt_row_shape" + assert "not one row" in error.message + + +def test_a_dict_that_is_not_json_is_malformed(untouched): + # A set has no JSON form: json.dumps raises TypeError. + value = {"i": {"t": "users", "c": "email"}, "v": {1, 2}} + error = refused([{"email": value, "age": 3}]) + assert error.code == "decrypt_malformed" + assert error.field == "email" + assert "row 0" in error.message + + +def test_one_tuple_row_of_eql_values_is_refused_as_one_row(untouched): + value = json.loads(STORED) + error = refused((value, 3)) + assert error.code == "decrypt_row_shape" + assert "one EQL value" in error.message + + +def test_a_str_that_is_not_valid_utf8_is_malformed(untouched): + # A lone surrogate: a str Python holds, with no UTF-8 encoding. + error = refused([{"email": "\ud800", "age": 3}]) + assert error.code == "decrypt_malformed" + assert error.field == "email" + + +class Boom(Exception): + """An error that is not about the input's JSON or type.""" + + +class ExplodingDict(dict): + """A dict whose items, which json.dumps reads, raise Boom.""" + + def items(self): + raise Boom("not about the JSON") + + +def test_an_error_that_is_not_about_the_json_propagates(untouched): + with pytest.raises(Boom): + Cipher(User).decrypt([{"email": ExplodingDict(i={}), "age": 3}]) + + +class NoOffset(datetime.tzinfo): + def utcoffset(self, dt): + return None + + +class RaisingOffset(datetime.tzinfo): + def utcoffset(self, dt): + raise Boom("the user's own tzinfo") + + +@stackencrypt.model("visits") +@dataclasses.dataclass +class Visit: + email: Annotated[str, TextEq] + seen: Annotated[datetime.datetime, Passthrough] + + +VISIT_STORED = json.dumps({"i": {"t": "visits", "c": "email"}, "v": 2}) + + +def test_a_passthrough_datetime_whose_tzinfo_gives_no_offset_is_naive(untouched): + seen = datetime.datetime(2026, 1, 1, tzinfo=NoOffset()) + with pytest.raises(InvalidValueError) as raised: + Cipher(Visit).decrypt([{"email": VISIT_STORED, "seen": seen}]) + assert raised.value.code == "value_naive_datetime" + assert raised.value.field == "seen" + + +def test_an_error_from_a_passthrough_value_propagates(untouched): + seen = datetime.datetime(2026, 1, 1, tzinfo=RaisingOffset()) + with pytest.raises(Boom): + Cipher(Visit).decrypt([{"email": VISIT_STORED, "seen": seen}]) + + +def test_a_row_without_a_declared_column_names_it(untouched): + error = refused([{"email": STORED}]) + assert error.code == "decrypt_missing_column" + assert error.field == "age" + + +@pytest.mark.parametrize( + "stored", + [ + json.dumps({"i": {"t": "users", "c": "login"}}), + json.dumps({"i": {"t": "accounts", "c": "email"}}), + ], + ids=["another-column", "another-table"], +) +def test_a_value_stored_under_another_column_is_a_foreign_row(untouched, stored): + error = refused([{"email": stored, "age": 3}]) + assert error.code == "decrypt_foreign_row" + assert error.field == "email" + + +@pytest.mark.parametrize( + "value", + [STORED.encode(), json.loads(STORED)], + ids=["bytes", "dict"], +) +def test_bytes_and_dict_values_are_read_as_eql_json(untouched, value): + # Each form reaches the same checks as the str: the row is refused for + # its passthrough, after the EQL value was read and accepted. + error = refused([{"email": value, "age": "3"}], InvalidValueError) + assert error.field == "age" + + +@pytest.mark.parametrize( + "value", + [STORED[: len(STORED) // 2], b"\xff\xfe", 42, None, "[]"], + ids=["truncated", "not-utf-8", "int", "null", "not-an-object"], +) +def test_a_value_that_is_not_eql_json_is_malformed(untouched, value): + error = refused([{"email": value, "age": 3}]) + assert error.code == "decrypt_malformed" + assert error.field == "email" + + +@pytest.mark.parametrize("value", [None, ""], ids=["null", "empty"]) +def test_a_malformed_value_names_its_row(untouched, value): + rows = [{"email": STORED, "age": i} for i in range(2)] + [{"email": value, "age": 2}] + error = refused(rows) + assert error.code == "decrypt_malformed" + assert "row 2" in error.message + + +def test_a_passthrough_int_that_arrives_as_str_is_the_wrong_type(untouched): + error = refused([{"email": STORED, "age": "3"}], InvalidValueError) + assert error.code == "value_wrong_type" + assert error.field == "age" + assert error.reason == "passthrough" + + +@stackencrypt.model("keyed") +@dataclasses.dataclass +class Keyed: + email: Annotated[str, TextEq] + id: Annotated[str, Passthrough] + + +def test_a_uuid_for_a_str_passthrough_is_the_wrong_type(untouched): + # The README's UUID advice depends on this: psycopg reads a uuid column + # as a uuid.UUID, and decrypt does not convert it to str. + stored = json.dumps({"i": {"t": "keyed", "c": "email"}, "v": 2}) + with pytest.raises(InvalidValueError) as raised: + Cipher(Keyed).decrypt([{"email": stored, "id": uuid.uuid4()}]) + assert raised.value.code == "value_wrong_type" + assert raised.value.field == "id" + + +def test_a_null_passthrough_is_refused(untouched): + error = refused([{"email": STORED, "age": None}], InvalidValueError) + assert error.code == "value_none" + assert error.field == "age" + + +def test_one_bad_row_in_ten_fails_the_call_before_any_request(untouched): + rows = [{"email": STORED, "age": i} for i in range(9)] + [{"email": STORED}] + error = refused(rows) + assert error.code == "decrypt_missing_column" + assert "row 9" in error.message + + +def test_extra_columns_are_not_read(untouched): + # The extra column holds what every check would refuse. The row is + # refused for its passthrough instead, so the extra column was not read. + error = refused([{"email": STORED, "age": "3", "id": object()}], InvalidValueError) + assert error.field == "age" + + +def test_a_row_that_passes_every_check_reaches_the_engine(): + # Refused later by the engine for want of a client key, not by the row + # checks: they passed. + with pytest.raises(StashError) as raised: + Cipher(User).decrypt([{"email": STORED, "age": 3}]) + assert not isinstance(raised.value, (DecryptError, InvalidValueError)) diff --git a/languages/python/tests/test_decrypt_rows.py b/languages/python/tests/test_decrypt_rows.py new file mode 100644 index 0000000000..a046214e8c --- /dev/null +++ b/languages/python/tests/test_decrypt_rows.py @@ -0,0 +1,122 @@ +"""``Cipher.decrypt_rows`` before any request: the row rules. + +These tests need fake credentials only, and make no request. The live round +trip is in ``test_encrypt_live.py``. +""" + +import dataclasses +import json +from typing import Annotated + +import pytest + +import stackencrypt +from stackencrypt import ( + Cipher, + DecryptError, + Ignore, + InvalidValueError, + Passthrough, + StashError, + Text, + TextEq, +) + +# The `i` of a value stored under people.email, and the rest of an EQL value +# left out: the checks before any request read only `i`. +STORED = json.dumps({"i": {"t": "people", "c": "email"}, "v": 2}) + +pytestmark = pytest.mark.usefixtures("credentials") + + +@stackencrypt.model("people") +@dataclasses.dataclass +class Person: + email: Annotated[str, TextEq] + name: Annotated[str, Text] + age: Annotated[int, Passthrough] + id: Annotated[int, Ignore] = 0 + + +@pytest.fixture +def people(): + return Cipher(Person) + + +def refused(cipher, rows, error=DecryptError): + with pytest.raises(error) as raised: + cipher.decrypt_rows(rows) + return raised.value + + +def test_an_empty_list_is_an_empty_list_with_no_request(people, untouched): + assert people.decrypt_rows([]) == [] + + +def test_rows_with_no_declared_field_come_back_unchanged_with_no_request(people, untouched): + rows = [{"people_id": 1}, {"people_id": 2, "id": 7}] + assert people.decrypt_rows(rows) == rows + + +def test_one_row_on_its_own_is_refused_as_one_row(people, untouched): + error = refused(people, {"email": STORED, "age": 3}) + assert error.code == "decrypt_row_shape" + assert "not one row" in error.message + + +def test_one_tuple_row_on_its_own_is_refused_as_one_row(people, untouched): + # A tuple row is a sequence of its column values. Passed alone, each + # EQL value would be read as a row with no declared field, and come + # back unchanged with no error. + value = json.loads(STORED) + error = refused(people, (value, value)) + assert error.code == "decrypt_row_shape" + assert "one EQL value" in error.message + + +def test_a_sqlalchemy_row_on_its_own_is_refused(people, untouched): + from sqlalchemy import create_engine, text + + with create_engine("sqlite://").connect() as connection: + row = connection.execute(text("SELECT 1 AS age")).first() + error = refused(people, row) + assert error.code == "decrypt_row_shape" + assert "_mapping" in error.message + + +def test_a_row_that_is_not_a_mapping_is_refused(people, untouched): + error = refused(people, [{"email": STORED}, (STORED,)]) + assert error.code == "decrypt_row_shape" + assert "row 1" in error.message + + +def test_rows_with_different_fields_are_refused(people, untouched): + error = refused(people, [{"email": STORED}, {"email": STORED, "age": 3}]) + assert error.code == "decrypt_row_shape" + assert "row 1" in error.message + + +@pytest.mark.parametrize("value", [STORED[:10], 42, None], ids=["truncated", "int", "null"]) +def test_a_value_that_is_not_eql_json_is_malformed(people, untouched, value): + error = refused(people, [{"email": value}]) + assert error.code == "decrypt_malformed" + assert error.field == "email" + + +def test_a_value_stored_under_another_column_is_a_foreign_row(people, untouched): + error = refused(people, [{"name": STORED}]) + assert error.code == "decrypt_foreign_row" + assert error.field == "name" + + +def test_a_passthrough_of_the_wrong_type_is_refused(people, untouched): + error = refused(people, [{"email": STORED, "age": "3"}], InvalidValueError) + assert error.field == "age" + + +def test_a_row_that_passes_every_check_reaches_the_engine(people): + # Refused later by the engine for want of a client key, not by the row + # checks: they passed. + with pytest.raises(StashError) as raised: + people.decrypt_rows([{"email": STORED, "people_id": 1}]) + assert not isinstance(raised.value, (DecryptError, InvalidValueError)) diff --git a/languages/python/tests/test_encrypt.py b/languages/python/tests/test_encrypt.py new file mode 100644 index 0000000000..bfcccc455a --- /dev/null +++ b/languages/python/tests/test_encrypt.py @@ -0,0 +1,232 @@ +"""``Cipher.encrypt`` before any request: the value rules and the empty list. + +Every value is checked while the models are written into the engine's input +frame, before the shared client is built. So these tests need fake +credentials only, and make no request. The live round trip is in +``test_encrypt_live.py``. +""" + +import dataclasses +import datetime +import decimal +import math +from typing import Annotated + +import pytest + +import stackencrypt +from stackencrypt import ( + Bigint, + Cipher, + Date, + Double, + InvalidValueError, + Numeric, + Passthrough, + Real, + Smallint, + StashError, + TextEq, + Timestamp, +) + +SECRET = "secret@example.com" + + +pytestmark = pytest.mark.usefixtures("credentials") + + +def cipher_for(**fields): + """A Cipher for a dataclass with ``email: TextEq`` and ``fields``.""" + annotations = {"email": Annotated[str, TextEq], **fields} + cls = dataclasses.make_dataclass("User", annotations.items()) + return Cipher(stackencrypt.model("users")(cls)), cls + + +def refused(cipher, model): + """The refusal of ``model``. Neither the valid email nor the refused + value may appear in it. None and a bool are not checked: their text can + appear in a message for another reason.""" + with pytest.raises(InvalidValueError) as raised: + cipher.encrypt([model]) + error = raised.value + assert SECRET not in str(error) + value = getattr(model, error.field) if error.field else None + if value is not None and not isinstance(value, bool): + for shown in {repr(value), str(value)}: + assert shown not in str(error), f"{shown} is in {error}" + return error + + +def test_an_empty_list_is_an_empty_list_with_no_request(untouched): + cipher, _ = cipher_for() + assert cipher.encrypt([]) == [] + + +def test_an_invalid_value_error_is_a_stash_error_and_a_value_error(): + assert issubclass(InvalidValueError, StashError) + assert issubclass(InvalidValueError, ValueError) + + +def test_a_bool_on_an_int_field_is_the_wrong_type(untouched): + cipher, User = cipher_for(age=Annotated[int, Bigint]) + error = refused(cipher, User(email=SECRET, age=True)) + assert error.code == "value_wrong_type" + assert error.field == "age" + assert error.reason == "int64" + assert "bool" in error.message + + +def test_a_datetime_on_a_date_field_is_the_wrong_type(untouched): + cipher, User = cipher_for(born=Annotated[datetime.date, Date]) + now = datetime.datetime.now(datetime.timezone.utc) + error = refused(cipher, User(email=SECRET, born=now)) + assert error.code == "value_wrong_type" + assert error.field == "born" + + +def test_none_on_a_sealed_field_is_refused(untouched): + cipher, User = cipher_for() + error = refused(cipher, User(email=None)) + assert error.code == "value_none" + assert error.field == "email" + assert error.reason == "string" + + +def test_none_on_a_passthrough_field_is_refused(untouched): + cipher, User = cipher_for(age=Annotated[int, Passthrough]) + error = refused(cipher, User(email=SECRET, age=None)) + assert error.code == "value_none" + assert error.reason == "passthrough" + + +def test_a_passthrough_value_of_another_type_is_refused(untouched): + cipher, User = cipher_for(age=Annotated[int, Passthrough]) + error = refused(cipher, User(email=SECRET, age="3")) + assert error.code == "value_wrong_type" + assert error.field == "age" + + +def test_an_int_outside_its_kind_is_out_of_range(untouched): + cipher, User = cipher_for(rank=Annotated[int, Smallint]) + error = refused(cipher, User(email=SECRET, rank=40_123)) + assert error.code == "value_out_of_range" + assert error.field == "rank" + assert error.reason == "int16" + + +def test_an_int_wider_than_128_bits_is_out_of_range(untouched): + cipher, User = cipher_for(age=Annotated[int, Passthrough]) + error = refused(cipher, User(email=SECRET, age=2**200)) + assert error.code == "value_out_of_range" + + +def test_a_float_that_float32_cannot_hold_is_not_representable(untouched): + cipher, User = cipher_for(score=Annotated[float, Real]) + error = refused(cipher, User(email=SECRET, score=0.1)) + assert error.code == "value_not_representable" + assert error.reason == "float32" + + +def test_a_float64_takes_any_float(untouched): + # Refused later by the engine for want of credentials, not by the value + # rules: the value check passed. + cipher, User = cipher_for(score=Annotated[float, Double]) + with pytest.raises(StashError) as raised: + cipher.encrypt([User(email=SECRET, score=0.1)]) + assert not isinstance(raised.value, InvalidValueError) + + +@pytest.mark.parametrize( + "value", + [decimal.Decimal("NaN"), decimal.Decimal("Infinity"), decimal.Decimal("1e-29")], + ids=["nan", "infinity", "scale-29"], +) +def test_a_decimal_outside_the_engines_bounds_is_not_representable(untouched, value): + cipher, User = cipher_for(price=Annotated[decimal.Decimal, Numeric]) + error = refused(cipher, User(email=SECRET, price=value)) + assert error.code == "value_not_representable" + assert error.reason == "decimal" + + +def test_a_naive_datetime_is_refused(untouched): + cipher, User = cipher_for(seen=Annotated[datetime.datetime, Timestamp]) + error = refused(cipher, User(email=SECRET, seen=datetime.datetime(2026, 1, 1))) + assert error.code == "value_naive_datetime" + assert "tzinfo" in error.message + + +class NoOffset(datetime.tzinfo): + """A tzinfo that gives no offset: Python counts its datetime as naive.""" + + def utcoffset(self, dt): + return None + + +def test_a_datetime_whose_tzinfo_gives_no_offset_is_naive(untouched): + cipher, User = cipher_for(seen=Annotated[datetime.datetime, Timestamp]) + seen = datetime.datetime(2026, 1, 1, tzinfo=NoOffset()) + error = refused(cipher, User(email=SECRET, seen=seen)) + assert error.code == "value_naive_datetime" + + +def test_one_model_on_its_own_is_refused(untouched): + # A Pydantic model or a NamedTuple is iterable, so list() would split + # it into its fields. + cipher, User = cipher_for(age=Annotated[int, Passthrough]) + with pytest.raises(InvalidValueError) as raised: + cipher.encrypt(User(email=SECRET, age=3)) + assert raised.value.code == "value_wrong_type" + assert "list of models" in raised.value.message + + +def test_a_model_of_another_class_is_the_wrong_type(untouched): + # Contact has the same field names, so every getattr would succeed. + cipher, _ = cipher_for(age=Annotated[int, Passthrough]) + Contact = dataclasses.make_dataclass("Contact", [("email", str), ("age", int)]) + error = refused(cipher, Contact(email=SECRET, age=3)) + assert error.code == "value_wrong_type" + assert "model 0 is a Contact" in error.message + + +def test_every_model_is_checked_before_any_request(untouched): + # The bad value is in the last model of many. + cipher, User = cipher_for(age=Annotated[int, Bigint]) + models = [User(email=SECRET, age=i) for i in range(100)] + [User(email=SECRET, age=math.pi)] + with pytest.raises(InvalidValueError) as raised: + cipher.encrypt(models) + assert raised.value.field == "age" + + +def test_a_property_that_raises_propagates_unchanged(untouched): + @stackencrypt.model("users") + class User: + email: Annotated[str, TextEq] + + def __init__(self, email): + self._email = email + + @property + def email(self): + raise LookupError("the user's own error") + + with pytest.raises(LookupError, match="the user's own error"): + Cipher(User).encrypt([User(email=SECRET)]) + + +def test_a_zero_decimal_with_a_huge_exponent_passes_the_value_check(): + # The read is constant time: a loop per unit of exponent would hang here. + # The call then fails building the client, with no client key set. + cipher, User = cipher_for(price=Annotated[decimal.Decimal, Numeric]) + with pytest.raises(StashError) as raised: + cipher.encrypt([User(email=SECRET, price=decimal.Decimal("0E+999999999999999999"))]) + assert not isinstance(raised.value, InvalidValueError) + + +def test_a_bad_zerokms_host_is_refused_naming_it(monkeypatch, untouched): + monkeypatch.setenv("CS_ZEROKMS_HOST", "not a url") + cipher, User = cipher_for() + with pytest.raises(StashError) as raised: + cipher.encrypt([User(email=SECRET)]) + assert raised.value.code == "stack_kms_invalid_endpoint" + assert "CS_ZEROKMS_HOST" in str(raised.value) diff --git a/languages/python/tests/test_encrypt_live.py b/languages/python/tests/test_encrypt_live.py new file mode 100644 index 0000000000..0f4704ac5a --- /dev/null +++ b/languages/python/tests/test_encrypt_live.py @@ -0,0 +1,685 @@ +"""``Cipher.encrypt``, ``Cipher.decrypt`` and the query methods against ZeroKMS +and a PostgreSQL with the EQL v4 bundle. + +These tests need CipherStash credentials (``CS_CLIENT_ACCESS_KEY``, +``CS_WORKSPACE_CRN``, ``CS_CLIENT_ID``, ``CS_CLIENT_KEY``) and a database +URL in ``EQL_TEST_DATABASE_URL`` whose database has the EQL v4 bundle +installed. Without them every test here skips. ``mise run test:live`` +installs the bundle and runs them, and fails if any skips. +""" + +import dataclasses +import datetime +import decimal +import json +import math +import struct +from typing import Annotated, List, NamedTuple, Optional + +import attrs +import pydantic +import pytest +from _live import DATABASE_URL, LIVE, needs_database +from sqlalchemy import ForeignKey +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + +import stackencrypt +from stackencrypt import ( + Cipher, + DecryptError, + Ignore, + IntegerOrd, + Passthrough, + Text, + TextEq, + TextMatch, +) +from stackencrypt import _engine, _eql + +pytestmark = LIVE + + +@stackencrypt.model("users") +@dataclasses.dataclass +class User: + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + + +@stackencrypt.model("contacts") +@dataclasses.dataclass +class Contact: + email: Annotated[str, TextEq] + + +class Refused(Exception): + """The model's own error, from its constructor.""" + + +@stackencrypt.model("contacts") +@dataclasses.dataclass +class StrictContact: + """Contact's table and column, with a constructor that refuses a value.""" + + email: Annotated[str, TextEq] + + def __post_init__(self): + if self.email == "bad@example.com": + raise Refused("the model refused this email") + + +def test_an_error_from_the_constructor_reaches_the_caller_unchanged(): + rows = Cipher(Contact).encrypt( + [Contact(email="good@example.com"), Contact(email="bad@example.com")] + ) + + with pytest.raises(Refused, match="the model refused this email"): + Cipher(StrictContact).decrypt(rows) + + +@needs_database +def test_an_encrypted_row_inserts_with_a_plain_parameter(): + psycopg = pytest.importorskip("psycopg") + rows = Cipher(User).encrypt([User(email="a@example.com", age=3)]) + + assert set(rows[0]) == {"email", "age"} + assert isinstance(rows[0]["email"], str) + assert rows[0]["age"] == 3 + with psycopg.connect(DATABASE_URL) as conn, conn.cursor() as cur: + cur.execute( + "CREATE TEMPORARY TABLE users (email public.eql_v4_text_eq, age int)" + ) + # The README's insert: a plain %s, no cast. Postgres infers the + # domain from the column for a str parameter. + cur.execute( + "INSERT INTO users (email, age) VALUES (%s, %s)", + (rows[0]["email"], rows[0]["age"]), + ) + cur.execute("SELECT email IS NOT NULL, pg_typeof(email)::text, age FROM users") + assert cur.fetchone() == (True, "eql_v4_text_eq", 3) + + +def test_the_plaintext_is_not_in_the_encrypted_column(): + rows = Cipher(User).encrypt([User(email="hidden@example.com", age=3)]) + assert "hidden@example.com" not in rows[0]["email"] + + +def test_1001_one_field_models_make_three_key_requests(): + cipher = Cipher(Contact) + # The first call in the process may load the keyset. Warm the client up + # first, so the count below is the key requests alone. + cipher.encrypt([Contact(email="warm@example.com")]) + before = _engine.request_count() + + cipher.encrypt([Contact(email=f"user{i}@example.com") for i in range(1001)]) + + # ceil(1001 / 500): stack-kms's 500 keys per request. + assert _engine.request_count() - before == 3 + + +def test_the_same_value_encrypts_to_a_different_ciphertext_each_time(): + # A reused nonce, or a deterministic seal, would give equal payloads. + users = Cipher(User) + model = User(email="same@example.com", age=3) + + first, second = users.encrypt([model, model]) + again = users.encrypt([model])[0] + + ciphertexts = {json.loads(row["email"])["c"] for row in (first, second, again)} + assert len(ciphertexts) == 3 + + +def test_two_ciphers_share_one_engine_client(): + Cipher(User).encrypt([User(email="a@example.com", age=1)]) + # Read after the first call: an earlier test may have loaded the keyset + # more than once, if a load failed and the next call loaded it again. + loads = _engine.requests().get("load-keyset") + # A missing counter would make the last check compare None with None. + assert loads is not None and loads >= 1, _engine.requests() + + Cipher(Contact).encrypt([Contact(email="b@example.com")]) + + assert _engine.client_builds() == 1 + assert _engine.requests().get("load-keyset") == loads + + +@stackencrypt.model("prices") +@dataclasses.dataclass +class Price: + sku: Annotated[str, TextEq] + amount: Annotated[decimal.Decimal, Passthrough] + + +def test_a_decimal_passthrough_comes_back_as_the_same_decimal(): + rows = Cipher(Price).encrypt([Price(sku="a", amount=decimal.Decimal("19.50"))]) + amount = rows[0]["amount"] + assert type(amount) is decimal.Decimal + # The scale is kept: 19.50, not 19.5. + assert str(amount) == "19.50" + + +# The five class kinds, each with one TextEq, one Passthrough and one Ignore. + + +@stackencrypt.model("users") +@dataclasses.dataclass +class DataclassUser: + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + note: Annotated[str, Ignore] = "" + + +@stackencrypt.model("users") +@attrs.define +class AttrsUser: + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + note: Annotated[str, Ignore] = "" + + +@stackencrypt.model("users") +class PydanticUser(pydantic.BaseModel): + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + note: Annotated[str, Ignore] = "" + + +class TupleUser(NamedTuple): + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + note: Annotated[str, Ignore] = "" + + +stackencrypt.model("users")(TupleUser) + + +class Base(DeclarativeBase): + pass + + +class Post(Base): + __tablename__ = "posts" + id: Mapped[Annotated[int, Passthrough]] = mapped_column(primary_key=True) + user_id: Mapped[Annotated[int, Passthrough]] = mapped_column(ForeignKey("users.id")) + + +@stackencrypt.model("users") +class SqlAlchemyUser(Base): + __tablename__ = "users" + id: Mapped[Annotated[int, Passthrough]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] + age: Mapped[Annotated[int, Passthrough]] + # SQLAlchemy does not look inside Annotated for the target, so the + # relationship names it. Without "Post", the first instance fails to + # configure the mapper. posts is still not a list: see spec #1341, the + # note on the Ignore relationship form. + posts: Mapped[Annotated[List["Post"], Ignore]] = relationship("Post") + + +def build(cls, at): + """The model for row ``at``: an id only where the class declares one.""" + fields = {"email": f"user{at}@example.com", "age": 30 + at} + if cls is SqlAlchemyUser: + fields["id"] = at + 1 + return cls(**fields) + + +def fields(model): + return {name: getattr(model, name) for name in ("email", "age")} + + +CLASS_KINDS = [DataclassUser, AttrsUser, PydanticUser, TupleUser, SqlAlchemyUser] + + +@needs_database +@pytest.mark.parametrize("cls", CLASS_KINDS, ids=lambda cls: cls.__name__) +def test_rows_read_back_with_dict_row_decrypt_into_the_models(cls): + psycopg = pytest.importorskip("psycopg") + from psycopg.rows import dict_row + + cipher = Cipher(cls) + models = [build(cls, at) for at in range(3)] + rows = cipher.encrypt(models) + with psycopg.connect(DATABASE_URL) as conn, conn.cursor(row_factory=dict_row) as cur: + cur.execute( + "CREATE TEMPORARY TABLE users " + "(id serial PRIMARY KEY, email public.eql_v4_text_eq, age int)" + ) + for row in rows: + cur.execute("INSERT INTO users (email, age) VALUES (%s, %s)", (row["email"], row["age"])) + # SELECT *: the id column is extra for every class but SQLAlchemy's, + # and is ignored. + cur.execute("SELECT * FROM users ORDER BY id") + decrypted = cipher.decrypt(cur.fetchall()) + + assert [type(model) for model in decrypted] == [cls] * 3 + assert [fields(model) for model in decrypted] == [fields(model) for model in models] + + +def test_str_bytes_and_dict_forms_decrypt_to_the_same_value(): + cipher = Cipher(User) + [row] = cipher.encrypt([User(email="forms@example.com", age=3)]) + forms = [row["email"], row["email"].encode(), json.loads(row["email"])] + + decrypted = cipher.decrypt([{"email": form, "age": 3} for form in forms]) + + assert [user.email for user in decrypted] == ["forms@example.com"] * 3 + + +def test_a_value_encrypted_for_another_column_is_a_foreign_row(): + [row] = Cipher(Contact).encrypt([Contact(email="a@example.com")]) + with pytest.raises(DecryptError) as raised: + Cipher(User).decrypt([{"email": row["email"], "age": 3}]) + assert raised.value.code == "decrypt_foreign_row" + assert raised.value.field == "email" + + +def test_a_sqlalchemy_class_is_built_without_its_ignored_relationship(): + cipher = Cipher(SqlAlchemyUser) + rows = cipher.encrypt([build(SqlAlchemyUser, 0)]) + + [user] = cipher.decrypt(rows) + + assert (user.id, user.email, user.age) == (1, "user0@example.com", 30) + assert "posts" not in user.__dict__ + + +def test_1001_rows_open_with_three_key_requests(): + cipher = Cipher(Contact) + rows = cipher.encrypt([Contact(email=f"user{i}@example.com") for i in range(1001)]) + before = _engine.request_count() + + decrypted = cipher.decrypt(rows) + + # ceil(1001 / 500): the keys for the whole list, 500 per request. + assert _engine.request_count() - before == 3 + assert decrypted[1000].email == "user1000@example.com" + + +@needs_database +def test_a_query_term_finds_the_one_matching_row(): + psycopg = pytest.importorskip("psycopg") + from psycopg.rows import dict_row + + cipher = Cipher(User) + rows = cipher.encrypt( + [User(email="a@example.com", age=1), User(email="b@example.com", age=2)] + ) + with psycopg.connect(DATABASE_URL) as conn, conn.cursor(row_factory=dict_row) as cur: + cur.execute("CREATE TEMPORARY TABLE users (email public.eql_v4_text_eq, age int)") + for row in rows: + cur.execute("INSERT INTO users (email, age) VALUES (%s, %s)", (row["email"], row["age"])) + # The README's query: the term cast to the marker's query domain. + cur.execute( + f"SELECT * FROM users WHERE email = %s::{TextEq.query_sql_domain}", + (cipher.eq("email", "a@example.com"),), + ) + found = cipher.decrypt(cur.fetchall()) + + assert found == [User(email="a@example.com", age=1)] + + +@needs_database +def test_a_query_uses_the_eq_term_index(): + psycopg = pytest.importorskip("psycopg") + + cipher = Cipher(User) + rows = cipher.encrypt([User(email=f"user{i}@example.com", age=i) for i in range(20)]) + with psycopg.connect(DATABASE_URL) as conn, conn.cursor() as cur: + cur.execute("CREATE TEMPORARY TABLE users (email public.eql_v4_text_eq, age int)") + # The README's index. + cur.execute("CREATE INDEX users_email_eq ON users USING hash (eql_v4.eq_term(email))") + for row in rows: + cur.execute("INSERT INTO users (email, age) VALUES (%s, %s)", (row["email"], row["age"])) + cur.execute("ANALYZE users") + # A table this small is cheaper to scan, so the planner is told not + # to. The test is that the index can answer the query at all. + cur.execute("SET LOCAL enable_seqscan = off") + cur.execute( + "EXPLAIN SELECT * FROM users WHERE email = %s::eql_v4.query_text_eq", + (cipher.eq("email", "user7@example.com"),), + ) + plan = "\n".join(line for (line,) in cur.fetchall()) + + assert "users_email_eq" in plan, plan + + +@stackencrypt.model("levels") +@dataclasses.dataclass +class Level: + n: Annotated[int, IntegerOrd] + + +ORDER_SQL = {"lt": "<", "lte": "<=", "gt": ">", "gte": ">="} +# The rows each operator finds against 3, among 1 to 5. +ORDER_FOUND = {"lt": [1, 2], "lte": [1, 2, 3], "gt": [4, 5], "gte": [3, 4, 5]} + + +@needs_database +@pytest.mark.parametrize("op", sorted(ORDER_SQL)) +def test_an_order_term_finds_the_rows_on_its_side(op): + psycopg = pytest.importorskip("psycopg") + from psycopg.rows import dict_row + + cipher = Cipher(Level) + rows = cipher.encrypt([Level(n=n) for n in [5, 1, 4, 2, 3]]) + with psycopg.connect(DATABASE_URL) as conn, conn.cursor(row_factory=dict_row) as cur: + cur.execute("CREATE TEMPORARY TABLE levels (n public.eql_v4_integer_ord)") + for row in rows: + cur.execute("INSERT INTO levels (n) VALUES (%s)", (row["n"],)) + cur.execute( + f"SELECT n FROM levels WHERE n {ORDER_SQL[op]} %s::{IntegerOrd.query_sql_domain}", + (getattr(cipher, op)("n", 3),), + ) + found = sorted(level.n for level in cipher.decrypt(cur.fetchall())) + + assert found == ORDER_FOUND[op] + + +@stackencrypt.model("notes") +@dataclasses.dataclass +class Note: + body: Annotated[str, TextMatch] + + +NOTES = ["Alice Smith", "Bob Jones", "Natalie Wood", "Carol King"] + + +def _notes_table(cur, cipher): + cur.execute("CREATE TEMPORARY TABLE notes (body public.eql_v4_text_match)") + for row in cipher.encrypt([Note(body=body) for body in NOTES]): + cur.execute("INSERT INTO notes (body) VALUES (%s)", (row["body"],)) + + +@needs_database +def test_a_match_search_and_verify_find_the_rows_that_hold_the_text(): + psycopg = pytest.importorskip("psycopg") + from psycopg.rows import dict_row + + cipher = Cipher(Note) + q = cipher.matches("body", "ali") + with psycopg.connect(DATABASE_URL) as conn, conn.cursor(row_factory=dict_row) as cur: + _notes_table(cur, cipher) + cur.execute( + f"SELECT body FROM notes WHERE body @@ %s::{TextMatch.query_sql_domain}", + (q.term,), + ) + candidates = cipher.decrypt(cur.fetchall()) + + bodies = [note.body for note in candidates] + kept = sorted(body for body, ok in zip(bodies, q.verify(bodies)) if ok) + # The server's answer can hold extra rows, but never misses a match. + assert {"Alice Smith", "Natalie Wood"} <= set(bodies) + assert kept == ["Alice Smith", "Natalie Wood"] + + +@needs_database +def test_target_limit_reads_a_measured_histogram(): + psycopg = pytest.importorskip("psycopg") + + cipher = Cipher(Note) + q = cipher.matches("body", "ali") + with psycopg.connect(DATABASE_URL) as conn, conn.cursor() as cur: + _notes_table(cur, cipher) + # The query in MatchQuery.target_limit's docstring. + cur.execute( + "SELECT cardinality(eql_v4.match_term(body)), count(*) FROM notes " + "WHERE body IS NOT NULL GROUP BY 1" + ) + histogram = cur.fetchall() + + assert q.target_limit(2, rows=len(NOTES)) is None + limit = q.target_limit(2, rows=len(NOTES), histogram=histogram) + assert isinstance(limit, int) and limit >= 2, limit + + +def test_a_match_query_never_shows_its_text(): + q = Cipher(Note).matches("body", "zebracorn") + assert "zebracorn" not in repr(q) + assert "zebracorn" not in str(q) + assert not hasattr(q, "__dict__") + + +def test_100_queries_make_no_request(): + cipher = Cipher(User) + # The first call in the process may build the client and load the + # keyset. Warm it up first, so the count below is the queries alone. + cipher.eq("email", "warm@example.com") + before = _engine.request_count() + + terms = {cipher.eq("email", f"user{i}@example.com") for i in range(100)} + + assert _engine.request_count() == before + assert len(terms) == 100 + + +# One field per producible type. The json kind (Json and JsonSearch) is out +# of scope: it has no Python type in this version, and the decorator refuses +# it. +PRODUCIBLE = [m for m in _eql.TYPES if m.producible and m.plaintext != "json"] + +# A value and its Python type for each plaintext kind, near the edge of +# what the kind holds. +KIND_VALUES = { + "string": (str, "héllo\x00wörld"), + "int16": (int, -(2**15)), + "int32": (int, 2**31 - 1), + "int64": (int, -(2**63)), + "float32": (float, 1.5), + "float64": (float, 0.1), + "bool": (bool, True), + "date": (datetime.date, datetime.date(2024, 2, 29)), + "timestamp": ( + datetime.datetime, + datetime.datetime( + 2024, 2, 29, 23, 59, 59, 999999, + tzinfo=datetime.timezone(datetime.timedelta(hours=10)), + ), + ), + "decimal": (decimal.Decimal, decimal.Decimal("-1234.5600")), +} + + +def every_type_model(): + cls = dataclasses.make_dataclass( + "EveryType", + [(m.name.lower(), Annotated[KIND_VALUES[m.plaintext][0], m]) for m in PRODUCIBLE], + ) + return stackencrypt.model("every_type")(cls) + + +def test_every_producible_type_except_the_json_kind_is_covered(): + assert {m.plaintext for m in PRODUCIBLE} == set(KIND_VALUES) + assert len(PRODUCIBLE) == 49 + + +@needs_database +def test_every_producible_type_round_trips_through_postgres(): + psycopg = pytest.importorskip("psycopg") + from psycopg.rows import dict_row + + cls = every_type_model() + cipher = Cipher(cls) + model = cls(**{m.name.lower(): KIND_VALUES[m.plaintext][1] for m in PRODUCIBLE}) + [row] = cipher.encrypt([model]) + columns = [m.name.lower() for m in PRODUCIBLE] + with psycopg.connect(DATABASE_URL) as conn, conn.cursor(row_factory=dict_row) as cur: + cur.execute( + "CREATE TEMPORARY TABLE every_type (" + + ", ".join(f"{m.name.lower()} {m.sql_domain}" for m in PRODUCIBLE) + + ")" + ) + cur.execute( + f"INSERT INTO every_type ({', '.join(columns)}) " + f"VALUES ({', '.join(['%s'] * len(columns))})", + [row[column] for column in columns], + ) + cur.execute("SELECT * FROM every_type") + [decrypted] = cipher.decrypt(cur.fetchall()) + + for marker in PRODUCIBLE: + name = marker.name.lower() + assert exact(getattr(decrypted, name)) == exact(getattr(model, name)), name + + +def exact(value): + """A value in a form whose == also compares what Python's == does not: + a Decimal's scale (1.50 == 1.5 in Python), a float's sign (-0.0 == 0.0), + and a datetime's instant with no tzinfo dropped.""" + if isinstance(value, decimal.Decimal): + return value.as_tuple() + if isinstance(value, float): + return struct.pack("= CALL_MS / 1000 + + +def test_the_extension_is_usable_after_an_interrupt(): + timer = send_sigint_later(SIGNAL_AT_S) + try: + with pytest.raises(KeyboardInterrupt): + call_then_wait_for_the_interrupt() + finally: + timer.cancel() + timer.join() + + assert probe(10) >= 10 diff --git a/languages/python/tests/test_markers.py b/languages/python/tests/test_markers.py new file mode 100644 index 0000000000..76db131dd6 --- /dev/null +++ b/languages/python/tests/test_markers.py @@ -0,0 +1,93 @@ +"""The markers: the generated EQL-type markers and the two hand-written ones. + +The catalog itself is checked in Rust, where it lives: eql-codegen's +`py_eql_parity.rs` compares every marker with the Go `Types` table. These +tests check what a Python developer sees. +""" + +import pytest + +import stackencrypt +from stackencrypt import _eql +from stackencrypt._markers import Marker + +ATTRIBUTES = ( + "name", + "family", + "suffix", + "plaintext", + "sql_domain", + "indexes", + "query", + "query_sql_domain", + "producible", + "reason", +) + +UNPRODUCIBLE = [marker.name for marker in _eql.TYPES if not marker.producible] + + +def test_the_common_markers_import_from_the_package(): + from stackencrypt import Ignore, Passthrough, Text, TextEq + + assert issubclass(TextEq, Marker) + assert issubclass(Text, Marker) + assert issubclass(Passthrough, Marker) + assert issubclass(Ignore, Marker) + + +def test_text_eq_carries_the_catalog_row(): + from stackencrypt import TextEq + + assert {name: getattr(TextEq, name) for name in ATTRIBUTES} == { + "name": "TextEq", + "family": "text", + "suffix": "Eq", + "plaintext": "string", + "sql_domain": "public.eql_v4_text_eq", + "indexes": ("eq",), + "query": "TextEqQuery", + "query_sql_domain": "eql_v4.query_text_eq", + "producible": True, + "reason": None, + } + + +def test_a_marker_without_a_query_has_no_query_domain(): + from stackencrypt import Text + + assert Text.query is None + assert Text.query_sql_domain is None + + +@pytest.mark.parametrize("marker", _eql.TYPES, ids=lambda m: m.name) +def test_every_marker_carries_exactly_the_ten_attributes(marker): + generated = [name for name in vars(marker) if not name.startswith("__")] + assert tuple(generated) == ATTRIBUTES + assert marker.__name__ == marker.name + assert marker.producible is (marker.reason is None) + + +def test_types_lists_every_marker_class_in_the_module_once(): + classes = [ + value + for value in vars(_eql).values() + if isinstance(value, type) and issubclass(value, Marker) and value is not Marker + ] + assert len(_eql.TYPES) == len(set(_eql.TYPES)) + assert set(_eql.TYPES) == set(classes) + + +def test_the_package_exports_exactly_the_producible_markers(): + exported = set(_eql.__all__) + assert exported == {marker.name for marker in _eql.TYPES if marker.producible} + assert exported <= set(stackencrypt.__all__) + for name in exported: + assert getattr(stackencrypt, name) is getattr(_eql, name) + + +@pytest.mark.skipif(not UNPRODUCIBLE, reason="every catalog type is producible") +@pytest.mark.parametrize("name", UNPRODUCIBLE) +def test_an_unproducible_marker_does_not_import_from_the_package(name): + with pytest.raises(ImportError): + exec(f"from stackencrypt import {name}", {}) diff --git a/languages/python/tests/test_nullable.py b/languages/python/tests/test_nullable.py new file mode 100644 index 0000000000..6554f239b1 --- /dev/null +++ b/languages/python/tests/test_nullable.py @@ -0,0 +1,79 @@ +"""A field declared ``Optional``, before any request: ``None`` passes the +checks of ``encrypt``, ``decrypt`` and the ``*_rows`` methods, and a query +term refuses it. + +These tests need fake credentials only. A call that passes every check +fails building the engine client, with no request. The round trip is in +``test_encrypt_live.py``. +""" + +import dataclasses +import json +from typing import Annotated, Optional + +import pytest + +import stackencrypt +from stackencrypt import ( + Cipher, + DecryptError, + InvalidValueError, + Passthrough, + StashError, + TextEq, +) + +pytestmark = pytest.mark.usefixtures("credentials") + + +@stackencrypt.model("places") +@dataclasses.dataclass +class Place: + name: Annotated[str, TextEq] + city: Annotated[Optional[str], TextEq] + note: Annotated[Optional[str], Passthrough] + + +STORED = json.dumps({"i": {"t": "places", "c": "name"}, "v": 2}) + + +def reaches_the_engine(call): + # Refused later by the engine for want of a client key, not by the + # value checks: they passed. + with pytest.raises(StashError) as raised: + call() + assert not isinstance(raised.value, (InvalidValueError, DecryptError)) + + +def test_none_in_an_optional_field_passes_the_encrypt_checks(): + reaches_the_engine(lambda: Cipher(Place).encrypt([Place(name="a", city=None, note=None)])) + + +def test_none_in_a_field_that_is_not_optional_is_still_refused(untouched): + with pytest.raises(InvalidValueError) as raised: + Cipher(Place).encrypt([Place(name=None, city="Leeds", note=None)]) + assert raised.value.code == "value_none" + assert raised.value.field == "name" + + +def test_none_in_an_optional_field_passes_the_encrypt_rows_checks(): + reaches_the_engine(lambda: Cipher(Place).encrypt_rows([{"city": None, "note": None}])) + + +def test_a_null_column_of_an_optional_field_passes_the_decrypt_checks(): + reaches_the_engine(lambda: Cipher(Place).decrypt([{"name": STORED, "city": None, "note": None}])) + + +def test_a_null_column_of_a_field_that_is_not_optional_is_still_refused(untouched): + with pytest.raises(DecryptError) as raised: + Cipher(Place).decrypt([{"name": None, "city": None, "note": None}]) + assert raised.value.code == "decrypt_malformed" + assert raised.value.field == "name" + + +def test_a_query_term_for_none_is_refused_and_points_to_is_null(untouched): + with pytest.raises(InvalidValueError) as raised: + Cipher(Place).eq("city", None) + assert raised.value.code == "value_none" + assert raised.value.field == "city" + assert "IS NULL" in raised.value.message diff --git a/languages/python/tests/test_query.py b/languages/python/tests/test_query.py new file mode 100644 index 0000000000..4ef86c21ad --- /dev/null +++ b/languages/python/tests/test_query.py @@ -0,0 +1,214 @@ +"""The query methods before any request: the field checks and the value checks. + +``eq``, ``lt``, ``lte``, ``gt``, ``gte`` and ``matches`` (ADR-0003). A name that is not a +declared field, a field that does not support the operator, and a value that +does not fit are all refused before the shared client is built. +So these tests need fake credentials only, and make no request. The live +search, the index, and the request count are in ``test_encrypt_live.py``. +""" + +import dataclasses +from typing import Annotated + +import pytest + +import stackencrypt +from stackencrypt import ( + Cipher, + IntegerEq, + IntegerOrd, + InvalidValueError, + Passthrough, + QueryError, + StashError, + Text, + TextEq, + TextMatch, +) + +pytestmark = pytest.mark.usefixtures("credentials") + + +@stackencrypt.model("users") +@dataclasses.dataclass +class User: + email: Annotated[str, TextEq] + bio: Annotated[str, Text] + age: Annotated[int, Passthrough] + rank: Annotated[int, IntegerEq] + level: Annotated[int, IntegerOrd] + notes: Annotated[str, TextMatch] + + +ORDER = ["lt", "lte", "gt", "gte"] + + +def refused(field, value, error=QueryError, op="eq"): + with pytest.raises(error) as raised: + getattr(Cipher(User), op)(field, value) + return raised.value + + +def test_query_is_gone(): + # ADR-0003: one method for each operator, and no second way to make an + # equality term. + assert not hasattr(Cipher, "query") + + +def test_a_query_error_is_a_stash_error(): + assert issubclass(QueryError, StashError) + + +def test_an_unknown_name_lists_the_fields_with_a_query(untouched): + error = refused("emial", "a@example.com") + assert error.code == "query_unknown_field" + assert error.field == "emial" + assert "'emial'" in error.message + assert "Fields that support eq: 'email', 'rank'." in error.message + + +def test_a_field_that_is_not_a_str_is_an_unknown_name(untouched): + error = refused(None, "a@example.com") + assert error.code == "query_unknown_field" + assert error.field is None + + +def test_an_unknown_name_lists_the_fields_with_the_operator(untouched): + error = refused("lvl", 3, op="gte") + assert error.code == "query_unknown_field" + assert "Fields that support gte: 'level'." in error.message + + +def test_a_text_field_answers_no_query(untouched): + error = refused("bio", "hello") + assert error.code == "query_unsupported" + assert error.field == "bio" + assert error.reason == "Text" + + +def test_a_passthrough_field_answers_no_query(untouched): + error = refused("age", 3) + assert error.code == "query_unsupported" + assert error.field == "age" + assert error.reason == "Passthrough" + + +@pytest.mark.parametrize( + ("field", "value", "marker"), + [("level", 3, "IntegerOrd"), ("notes", "hello", "TextMatch")], +) +def test_a_field_with_a_query_type_but_no_eq_index_answers_no_eq( + untouched, field, value, marker +): + # The marker has a query type, for order or match. The engine answers + # eq through the eq index alone. + error = refused(field, value) + assert error.code == "query_unsupported" + assert error.field == field + assert error.reason == marker + assert "does not support eq" in error.message + + +@pytest.mark.parametrize("op", ORDER) +@pytest.mark.parametrize( + ("field", "value", "marker"), + [("email", "a@example.com", "TextEq"), ("notes", "hello", "TextMatch")], +) +def test_a_field_with_no_order_index_answers_no_order_operator( + untouched, op, field, value, marker +): + error = refused(field, value, op=op) + assert error.code == "query_unsupported" + assert error.field == field + assert error.reason == marker + assert f"does not support {op}" in error.message + + +@pytest.mark.parametrize("op", ORDER) +def test_a_passthrough_field_answers_no_order_operator(untouched, op): + error = refused("age", 3, op=op) + assert error.code == "query_unsupported" + assert error.reason == "Passthrough" + + +@pytest.mark.parametrize("op", ORDER) +def test_an_order_value_of_another_type_is_the_wrong_type(untouched, op): + error = refused("level", "3", InvalidValueError, op=op) + assert error.code == "value_wrong_type" + assert error.field == "level" + + +def test_a_class_with_no_queryable_field_says_so(untouched): + @stackencrypt.model("notes") + @dataclasses.dataclass + class Note: + body: Annotated[str, Text] + + with pytest.raises(QueryError) as raised: + Cipher(Note).eq("body", "x") + assert "The class has no field that supports eq." in raised.value.message + + +def test_a_value_of_another_type_is_the_wrong_type(untouched): + error = refused("email", 3, InvalidValueError) + assert error.code == "value_wrong_type" + assert error.field == "email" + assert error.reason == "string" + + +def test_a_bool_on_an_integer_field_is_the_wrong_type(untouched): + error = refused("rank", True, InvalidValueError) + assert error.code == "value_wrong_type" + assert error.field == "rank" + + +def test_none_is_refused(untouched): + error = refused("email", None, InvalidValueError) + assert error.code == "value_none" + + +def test_a_value_out_of_range_is_refused(untouched): + error = refused("rank", 2**31, InvalidValueError) + assert error.code == "value_out_of_range" + assert error.reason == "int32" + + +def test_the_message_never_contains_the_value(untouched): + error = refused("email", 123456789, InvalidValueError) + assert "123456789" not in str(error) + + +@pytest.mark.parametrize( + ("field", "value", "marker"), + [("email", "a@example.com", "TextEq"), ("level", 3, "IntegerOrd")], +) +def test_a_field_with_no_match_index_answers_no_matches(untouched, field, value, marker): + error = refused(field, value, op="matches") + assert error.code == "query_unsupported" + assert error.reason == marker + assert "does not support matches" in error.message + assert "Fields that support matches: 'notes'." in error.message + + +def test_matches_takes_text_only(untouched): + error = refused("notes", 3, InvalidValueError, op="matches") + assert error.code == "value_wrong_type" + assert error.field == "notes" + + +def test_matches_message_never_contains_the_text(untouched): + error = refused("email", "zebracorn", op="matches") + assert "zebracorn" not in str(error) + + +@pytest.mark.parametrize( + ("op", "field", "value"), + [("eq", "email", "a@example.com"), ("matches", "notes", "hello")] + + [(op, "level", 3) for op in ORDER], +) +def test_a_query_that_passes_every_check_reaches_the_engine(op, field, value): + # Refused later by the engine for want of a client key, not by the + # checks: they passed. + with pytest.raises(StashError) as raised: + getattr(Cipher(User), op)(field, value) + assert not isinstance(raised.value, (QueryError, InvalidValueError)) diff --git a/languages/python/tests/test_readme.py b/languages/python/tests/test_readme.py new file mode 100644 index 0000000000..a12ff7ab22 --- /dev/null +++ b/languages/python/tests/test_readme.py @@ -0,0 +1,65 @@ +"""The README's code, checked with no credentials. `test_readme_live.py` +runs it against ZeroKMS and PostgreSQL.""" + +import pathlib + +from _readme import all_blocks, blocks, bound_names, check_names, fence_lines, program + +MISE = pathlib.Path(__file__).resolve().parents[1] / "mise.toml" +BUNDLE = "packages/eql/release/cipherstash-encrypt-v4.sql" + + +def test_every_fence_is_read(): + # Each block has an opening and a closing fence line. A fence the parser + # does not match would drop its block from the live run without notice. + assert fence_lines() == 2 * len(all_blocks()) + + +def test_the_python_blocks_compile_as_one_program(): + compile(program(), "README.md", "exec") + + +def test_the_python_blocks_bind_the_names_the_live_check_reads(): + # At the top level: the check runs at the top level too, so a name bound + # only inside a function is not one it can read. + assert check_names() == {"dataclasses", "users"} + assert check_names() <= bound_names(program()) + + +def test_bound_names_keeps_only_the_top_level_names(): + source = ( + "import os.path as p\n" + "top = 1\n" + "def f():\n" + " inside_function = 2\n" + "class C:\n" + " inside_class = 3\n" + "squares = [n * n for n in range(3)]\n" + "lam = lambda x: x\n" + ) + assert bound_names(source) == {"p", "top", "f", "C", "squares", "lam"} + + +def test_bound_names_reads_except_as_and_a_walrus_in_a_comprehension(): + source = ( + "try:\n" + " pass\n" + "except ValueError as caught:\n" + " pass\n" + "found = [last := n for n in range(3)]\n" + ) + assert bound_names(source) == {"caught", "found", "last"} + + +def test_the_readme_has_sql_for_the_live_test_to_run(): + assert blocks("sql") + + +def test_the_readme_installs_the_bundle_the_live_tests_install(): + # The live task cannot run the README's psql command from the root, so it + # runs its own. Both must name the same bundle. + installs = [block for block in blocks("sh") if "psql" in block] + assert len(installs) == 1, "the README must have exactly one psql install block" + [install] = installs + assert BUNDLE in install + assert f"../../{BUNDLE}" in MISE.read_text(encoding="utf-8") diff --git a/languages/python/tests/test_readme_live.py b/languages/python/tests/test_readme_live.py new file mode 100644 index 0000000000..df66ef6d7b --- /dev/null +++ b/languages/python/tests/test_readme_live.py @@ -0,0 +1,36 @@ +"""The README, run as a reader runs it: its SQL creates the table, and its +Python blocks encrypt, insert, select, decrypt and query, in order. + +Needs the four CipherStash credentials and ``EQL_TEST_DATABASE_URL``, with +the EQL v4 bundle installed. The run gets a schema of its own, so the +README's ``users`` table meets no other test's. +""" + +import uuid + +import pytest +from _live import DATABASE_URL, LIVE, needs_database, run_python +from _readme import CHECK, blocks, program + +pytestmark = [*LIVE, needs_database] + + +def test_a_reader_following_the_readme_finds_the_row_by_equality(): + psycopg = pytest.importorskip("psycopg") + schema = f"readme_{uuid.uuid4().hex[:12]}" + # public stays on the path: EQL's operators, such as the `=` the query + # uses, are installed there unqualified. + url = psycopg.conninfo.make_conninfo(DATABASE_URL, options=f"-c search_path={schema},public") + with psycopg.connect(DATABASE_URL, autocommit=True) as conn: + conn.execute(f"CREATE SCHEMA {schema}") + try: + with psycopg.connect(url, autocommit=True) as conn: + for sql in blocks("sql"): + conn.execute(sql) + + found = run_python(["-c", program() + CHECK], env={"DATABASE_URL": url}) + finally: + with psycopg.connect(DATABASE_URL, autocommit=True) as conn: + conn.execute(f"DROP SCHEMA {schema} CASCADE") + + assert found == [{"email": "a@example.com", "age": 3}] diff --git a/languages/python/tests/test_redaction.py b/languages/python/tests/test_redaction.py new file mode 100644 index 0000000000..2bd222e6bf --- /dev/null +++ b/languages/python/tests/test_redaction.py @@ -0,0 +1,329 @@ +"""``repr`` and ``str`` of a declared class show no sealed value.""" + +import dataclasses +import functools +import reprlib +from typing import Annotated, NamedTuple + +import attrs +import pydantic +import pytest +from sqlalchemy import create_engine, event +from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column + +import stackencrypt +from stackencrypt import DeclarationError, Ignore, Passthrough, TextEq +from stackencrypt._redact import SEALED + +SECRET = "alice@example.com" +EXPECTED = "User(email=, age=42)" + + +def declare_dataclass(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + note: Annotated[str, Ignore] = "not shown" + + return User(email=SECRET, age=42) + + +def declare_attrs(): + @stackencrypt.model("users") + @attrs.define + class User: + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + note: Annotated[str, Ignore] = "not shown" + + return User(email=SECRET, age=42) + + +def declare_pydantic(): + @stackencrypt.model("users") + class User(pydantic.BaseModel): + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + note: Annotated[str, Ignore] = "not shown" + + return User(email=SECRET, age=42) + + +class User(NamedTuple): + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + note: Annotated[str, Ignore] = "not shown" + + +stackencrypt.model("users")(User) + + +def declare_named_tuple(): + return User(email=SECRET, age=42) + + +class Base(DeclarativeBase): + pass + + +@stackencrypt.model("users") +class MappedUser(Base): + __tablename__ = "users" + email: Mapped[Annotated[str, TextEq]] = mapped_column(primary_key=True) + age: Mapped[Annotated[int, Passthrough]] + # No Ignore field: on SQLAlchemy, an Ignore field is a relationship, and a + # relationship under Annotated loads as one object, not a list. + + +def declare_sqlalchemy(): + return MappedUser(email=SECRET, age=42) + + +def test_an_expired_or_detached_sqlalchemy_column_runs_no_select(): + # After a commit every attribute is expired: reading one runs a SELECT, + # and on a detached instance it raises. repr must do neither. + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + statements = [] + event.listen(engine, "before_cursor_execute", lambda *args: statements.append(args[2])) + expected = "MappedUser(email=, age=)" + with Session(engine) as session: + user = MappedUser(email=SECRET, age=42) + session.add(user) + session.commit() + statements.clear() + + assert repr(user) == expected + assert statements == [] + assert repr(user) == expected + assert statements == [] + + +KINDS = [ + declare_dataclass, + declare_attrs, + declare_pydantic, + declare_named_tuple, + declare_sqlalchemy, +] + + +@pytest.mark.parametrize("declare", KINDS, ids=lambda d: d.__name__) +def test_repr_and_str_seal_every_encrypted_field(declare): + user = declare() + expected = EXPECTED.replace("User", type(user).__name__) + + for shown in (repr(user), str(user), f"{user}", f"{user!r}"): + assert shown == expected + assert SECRET not in shown + assert "note" not in shown + # The hook rich prints through, so rich.print and a rich traceback with + # locals show the same. rich reprs each value itself, so a passthrough + # value is the value. Checked without rich: the hook is a plain method. + assert list(user.__rich_repr__()) == [("email", SEALED), ("age", 42)] + + +def test_an_attrs_class_with_a_generated_str_is_redacted(): + # attrs builds the __str__ of str=True as a plain function in + # attr/_make.py, not under its "`, and the method is still the user's. + source = ( + "@stackencrypt.model('users')\n" + "@dataclasses.dataclass\n" + "class User:\n" + " email: Annotated[str, TextEq]\n" + " def __repr__(self):\n" + " return self.email\n" + ) + namespace = dict(globals()) + + with pytest.raises(DeclarationError) as raised: + exec(compile(source, "", "exec"), namespace) + + assert raised.value.code == "declaration_repr_present" + + +def test_a_passthrough_value_is_read_without_running_a_descriptor(): + # A field that is not set is shown as unset, and no attribute read runs: + # a SQLAlchemy column that is not loaded would run a SELECT. + @stackencrypt.model("users") + class User: + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + + def __init__(self, **kwargs): + vars(self).update(kwargs) + + def __getattr__(self, name): + raise RuntimeError(f"{name} was read") + + assert repr(User(email=SECRET)) == "User(email=, age=)" + + +def test_a_class_level_default_is_shown(): + @stackencrypt.model("users") + class User: + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] = 7 + + def __init__(self, **kwargs): + vars(self).update(kwargs) + + assert repr(User(email=SECRET)) == "User(email=, age=7)" + assert repr(User(email=SECRET, age=8)) == "User(email=, age=8)" + + +def test_an_unset_slot_is_shown_as_unset(): + @stackencrypt.model("users") + class User: + __slots__ = ("email", "age") + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + + def __init__(self, **kwargs): + for name, value in kwargs.items(): + setattr(self, name, value) + + assert repr(User(email=SECRET)) == "User(email=, age=)" + assert repr(User(email=SECRET, age=9)) == "User(email=, age=9)" + + +def _repr_from_another_module(self): + return "elsewhere" + + +_repr_from_another_module.__module__ = "somewhere.else" + + +def build_with_a_hand_written_method(form): + if form == "recursive_repr": + + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + + @reprlib.recursive_repr() + def __repr__(self): + return f"User({self.email})" + + elif form == "staticmethod": + + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + __str__ = staticmethod(lambda: "user") + + elif form == "partialmethod": + + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + __str__ = functools.partialmethod(lambda self, text: text, "user") + + else: + + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + __repr__ = _repr_from_another_module + + +@pytest.mark.parametrize( + "form", ["recursive_repr", "staticmethod", "partialmethod", "another module"] +) +def test_a_hand_written_method_in_any_form_is_refused(form): + # Only a generator's own output is replaced. Anything else written on the + # class is the user's, and replacing it would drop their code silently. + error = pytest.raises(DeclarationError, build_with_a_hand_written_method, form).value + assert error.code == "declaration_repr_present" + + +@pytest.mark.parametrize("name", ["__str__", "__rich_repr__"]) +def test_each_hand_written_method_is_refused(name): + def method(self): + return self.email + + cls = dataclasses.make_dataclass( + "User", [("email", Annotated[str, TextEq])], namespace={name: method} + ) + error = pytest.raises(DeclarationError, stackencrypt.model("users"), cls).value + assert error.code == "declaration_repr_present" + assert name in str(error) + + +def test_redact_false_keeps_a_hand_written_repr_and_shows_plaintext(): + @stackencrypt.model("users", redact=False) + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + + def __repr__(self): + return f"User({self.email})" + + # The escape hatch is visibly dangerous: the plaintext is in the repr. + assert SECRET in repr(User(email=SECRET)) + + +def test_redact_false_keeps_a_generated_repr_and_shows_plaintext(): + @stackencrypt.model("users", redact=False) + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + + assert SECRET in repr(User(email=SECRET)) diff --git a/languages/python/tests/test_smoke.py b/languages/python/tests/test_smoke.py new file mode 100644 index 0000000000..ad26c217e4 --- /dev/null +++ b/languages/python/tests/test_smoke.py @@ -0,0 +1,31 @@ +"""The walking skeleton: the pure-Python package and the extension load together. + +One test per fact the first ticket promises. Nothing here encrypts. +""" + +import importlib.metadata +import re + +import stackencrypt + + +def test_version_is_the_installed_distribution_version(): + assert stackencrypt.__version__ == importlib.metadata.version("stackencrypt") + + +def test_version_starts_the_independent_semver_line(): + assert stackencrypt.__version__ == "0.1.0" + + +def test_engine_names_the_commit_the_extension_was_built_from(): + # `-dirty` when a packages/stack-* crate had local edits at build time. + assert re.fullmatch(r"[0-9a-f]{40}(-dirty)?", stackencrypt.__engine__) + + +def test_the_engine_function_answers_from_the_extension(): + # The one trivial engine call: both halves are loaded if it answers, and + # the answer is the same pair the package exposes as its two dunders. + assert stackencrypt._engine.build_info() == ( + stackencrypt.__version__, + stackencrypt.__engine__, + ) diff --git a/languages/python/tests/test_sqlalchemy.py b/languages/python/tests/test_sqlalchemy.py new file mode 100644 index 0000000000..d81b1341b5 --- /dev/null +++ b/languages/python/tests/test_sqlalchemy.py @@ -0,0 +1,911 @@ +"""``stackencrypt.sqlalchemy`` before any request: the type map, the class +checks of ``configure_encryption``, and the operators a query refuses. + +These tests need fake credentials only. They use an SQLite engine that no +test connects to. The hooks run against PostgreSQL in +``test_sqlalchemy_live.py``. +""" + +import dataclasses +from typing import Annotated, Optional + +import pytest +from sqlalchemy import ForeignKey, String, UniqueConstraint, create_engine, func, literal, select +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + +import stackencrypt +from stackencrypt import DeclarationError, Ignore, InvalidValueError, Passthrough, TextEq +from stackencrypt import IntegerOrd, QueryError, StashError, Text, _eql +from stackencrypt.sqlalchemy import EqlType, configure_encryption, eql_type_map + +pytestmark = pytest.mark.usefixtures("credentials") + + +class Base(DeclarativeBase): + type_annotation_map = eql_type_map + + +@stackencrypt.model("customers") +class Customer(Base): + __tablename__ = "customers" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] + plan: Mapped[Annotated[str, Passthrough]] + + +@pytest.fixture +def engine(): + return create_engine("sqlite://") + + +def refused(classes): + with pytest.raises(DeclarationError) as raised: + configure_encryption(classes) + return raised.value + + +def test_a_class_sqlalchemy_does_not_map_is_refused(engine): + @stackencrypt.model("contacts") + @dataclasses.dataclass + class Contact: + email: Annotated[str, TextEq] + + error = refused([Contact]) + assert error.code == "declaration_not_mapped" + assert "Contact" in error.message + + +def test_a_class_not_declared_is_refused(engine): + class Plain(Base): + __tablename__ = "plain" + id: Mapped[int] = mapped_column(primary_key=True) + + error = refused([Plain]) + assert error.code == "declaration_not_decorated" + + +def test_a_declared_table_that_is_not_the_mapped_table_is_refused(engine): + @stackencrypt.model("people") + class Person(Base): + __tablename__ = "persons" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] + + error = refused([Person]) + assert error.code == "declaration_table_mismatch" + assert "people" in error.message + assert "persons" in error.message + + +def test_a_field_whose_column_has_another_name_is_refused(engine): + @stackencrypt.model("accounts") + class Account(Base): + __tablename__ = "accounts" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] = mapped_column("email_address") + + error = refused([Account]) + assert error.code == "declaration_column_name" + assert error.field == "email" + assert "email_address" in error.message + + +def test_a_unique_encrypted_column_is_refused(engine): + @stackencrypt.model("logins") + class Login(Base): + __tablename__ = "logins" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] = mapped_column(unique=True) + + error = refused([Login]) + assert error.code == "declaration_unique_column" + assert error.field == "email" + assert "eql_v4.eq_term(email)" in error.message + + +def test_an_encrypted_column_in_a_unique_constraint_is_refused(engine): + @stackencrypt.model("seats") + class Seat(Base): + __tablename__ = "seats" + __table_args__ = (UniqueConstraint("venue", "holder"),) + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + venue: Mapped[Annotated[int, Ignore]] + holder: Mapped[Annotated[str, TextEq]] + + error = refused([Seat]) + assert error.code == "declaration_unique_column" + assert error.field == "holder" + + +def test_a_unique_plaintext_column_beside_an_encrypted_one_is_accepted(untouched): + @stackencrypt.model("passes") + class Pass(Base): + __tablename__ = "passes" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + code: Mapped[Annotated[str, Ignore]] = mapped_column(unique=True) + holder: Mapped[Annotated[str, TextEq]] + + configure_encryption([Pass]) + + +def test_a_repeat_call_for_a_configured_class_does_nothing(untouched): + @stackencrypt.model("members") + class Member(Base): + __tablename__ = "members" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] + + configure_encryption([Member]) + configure_encryption([Member]) + configure_encryption([Member, Member]) + + +def test_a_class_whose_declaration_changed_is_refused_on_a_repeat_call(): + @stackencrypt.model("pilots") + class Pilot(Base): + __tablename__ = "pilots" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] + + configure_encryption([Pilot]) + Pilot.__repr__ = lambda self: "Pilot(...)" + + assert refused([Pilot]).code == "declaration_class_changed" + + +def test_another_class_that_maps_a_configured_table_is_refused(): + @stackencrypt.model("crews") + class Crew(Base): + __tablename__ = "crews" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] + + class OtherBase(DeclarativeBase): + type_annotation_map = eql_type_map + + @stackencrypt.model("crews") + class CrewAgain(OtherBase): + __table__ = Crew.__table__ + id: Mapped[Annotated[int, Ignore]] + email: Mapped[Annotated[str, TextEq]] + + configure_encryption([Crew]) + error = refused([CrewAgain]) + + assert error.code == "declaration_already_configured" + assert "Crew" in error.message + + +def test_an_engine_made_after_the_call_is_encrypted_too(untouched): + import datetime + + from sqlalchemy.orm import Session + + @stackencrypt.model("flights") + class Flight(Base): + __tablename__ = "flights" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] + at: Mapped[Annotated[datetime.datetime, Passthrough]] + + configure_encryption([Flight]) + later = create_engine("sqlite://") + with later.begin() as conn: + conn.exec_driver_sql("CREATE TABLE flights (id INTEGER PRIMARY KEY, email TEXT, at TEXT)") + + # The write hook runs on the new engine: it checks the passthrough value. + with Session(later) as session, pytest.raises(InvalidValueError) as raised: + session.add(Flight(email="secret@example.com", at=datetime.datetime(2026, 1, 1))) + session.commit() + assert raised.value.code == "value_naive_datetime" + + +def test_a_table_of_the_same_name_that_is_not_configured_is_left_alone(untouched): + from sqlalchemy import Column, Integer, MetaData, Table, insert + + @stackencrypt.model("tickets_seen") + class Seen(Base): + __tablename__ = "tickets_seen" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] + + configure_encryption([Seen]) + # Another database's table, with the same name, and no encryption. + other = Table("tickets_seen", MetaData(), Column("id", Integer, primary_key=True), Column("email", String)) + engine = create_engine("sqlite://") + other.create(engine) + + with engine.begin() as conn: + conn.execute(insert(other), [{"email": "plain@example.com"}]) + assert conn.execute(other.select()).one().email == "plain@example.com" + + +def test_a_refused_list_configures_none_of_its_classes(engine): + @stackencrypt.model("guests") + class Guest(Base): + __tablename__ = "guests" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] + + @stackencrypt.model("visitors") + class Visitor(Base): + __tablename__ = "visits" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] + + refused([Guest, Visitor]) + # Guest was not configured by the refused call, so it can be now. + configure_encryption([Guest]) + + +def test_a_declared_mapped_class_is_accepted_with_no_request(engine, untouched): + configure_encryption([Customer]) + + +def test_a_sealed_value_inside_the_sql_is_refused_and_not_stored(untouched): + from sqlalchemy import insert, select, update + + @stackencrypt.model("leads") + class Lead(Base): + __tablename__ = "leads" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] + + engine = create_engine("sqlite://") + # SQLite has no EQL types, and the refusal comes before the database. + with engine.begin() as conn: + conn.exec_driver_sql("CREATE TABLE leads (id INTEGER PRIMARY KEY, email TEXT)") + configure_encryption([Lead]) + + for statement in [ + insert(Lead).values(email="secret@example.com"), + update(Lead).where(Lead.id == 1).values(email="secret@example.com"), + ]: + with engine.begin() as conn, pytest.raises(DeclarationError) as raised: + conn.execute(statement) + assert raised.value.code == "declaration_inline_value" + assert raised.value.field == "email" + assert "secret@example.com" not in str(raised.value) + + with engine.connect() as conn: + assert conn.execute(select(Lead.__table__)).all() == [] + + +# The type map. + + +def test_the_type_map_gives_every_declarable_marker_its_eql_type(): + declarable = [m for m in _eql.TYPES if m.producible and m.plaintext != "json"] + assert declarable + for marker in declarable: + # Two hints for each marker: the value type, and Optional of it. + types = [t for hint, t in eql_type_map.items() if hint.__metadata__ == (marker,)] + assert len(types) == 2 + for column_type in types: + assert isinstance(column_type, EqlType) + assert column_type.marker is marker + assert column_type.get_col_spec() == marker.sql_domain + + +def test_an_optional_field_gets_a_nullable_eql_column(): + @stackencrypt.model("places") + class Place(Base): + __tablename__ = "places" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + name: Mapped[Annotated[str, TextEq]] + city: Mapped[Annotated[Optional[str], TextEq]] + note: Mapped[Annotated[Optional[str], Passthrough]] + + columns = Place.__table__.c + assert (repr(columns.city.type), columns.city.nullable) == ("EqlType(TextEq)", True) + assert columns.name.nullable is False + assert columns.note.nullable is True + + +def test_a_column_of_another_type_is_refused(engine): + @stackencrypt.model("tickets") + class Ticket(Base): + __tablename__ = "tickets" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] = mapped_column(String) + + error = refused([Ticket]) + assert error.code == "declaration_column_type" + assert error.field == "email" + assert "eql_v4_text_eq" in error.message + + +# The operators a query refuses, before any request. + + +@stackencrypt.model("scores") +class Score(Base): + __tablename__ = "scores" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + level: Mapped[Annotated[int, IntegerOrd]] + note: Mapped[Annotated[str, Text]] + + +@pytest.fixture +def scores(credentials): + # Function scope, so that the fake credentials are set first. A repeat + # call for a configured class does nothing. + configure_encryption([Score]) + + +@pytest.mark.parametrize( + "build", + [ + lambda: Score.note.like("%secret%"), + lambda: Score.note.contains("secret"), + lambda: Score.note.in_(select(Score.note).scalar_subquery()), + lambda: Score.level.between(1, 9), + lambda: Score.level + 1, + ], + ids=["like", "contains", "in-a-subquery", "between", "add"], +) +def test_an_operator_that_would_send_the_value_is_refused(scores, untouched, build): + with pytest.raises(QueryError) as raised: + build() + assert raised.value.code == "query_unsupported" + assert "secret" not in str(raised.value) + + +@pytest.mark.parametrize( + "build", + [ + lambda: Score.note.is_(None), + lambda: Score.note.is_not(None), + lambda: Score.note == None, # noqa: E711 + lambda: Score.note != None, # noqa: E711 + lambda: Score.note.in_([]), + lambda: Score.note.not_in([]), + lambda: Score.level.asc(), + lambda: Score.level.desc(), + ], + ids=["is-null", "is-not-null", "equals-none", "not-equals-none", "in-nothing", "not-in-nothing", "asc", "desc"], +) +def test_an_operator_that_sends_no_value_is_allowed_with_no_request(scores, untouched, build): + build() + + +def test_a_comparison_on_a_class_not_configured_is_refused(untouched): + @stackencrypt.model("drafts") + class Draft(Base): + __tablename__ = "drafts" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + note: Mapped[Annotated[str, Text]] + + with pytest.raises(QueryError) as raised: + Draft.note == "secret" # noqa: B015 + assert raised.value.code == "query_not_configured" + assert "secret" not in str(raised.value) + + +# Passthrough values on writes. + + +def test_a_passthrough_value_is_checked_on_a_flush(untouched): + import datetime + + from sqlalchemy.orm import Session + + @stackencrypt.model("trips") + class Visit(Base): + __tablename__ = "trips" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] + at: Mapped[Annotated[datetime.datetime, Passthrough]] + + engine = create_engine("sqlite://") + with engine.begin() as conn: + conn.exec_driver_sql("CREATE TABLE trips (id INTEGER PRIMARY KEY, email TEXT, at TEXT)") + configure_encryption([Visit]) + + with Session(engine) as session, pytest.raises(InvalidValueError) as raised: + session.add(Visit(email="secret@example.com", at=datetime.datetime(2026, 1, 1))) + session.commit() + + assert raised.value.code == "value_naive_datetime" + assert raised.value.field == "at" + with engine.connect() as conn: + assert conn.exec_driver_sql("SELECT count(*) FROM trips").scalar() == 0 + + +# ORDER BY an encrypted column. + + +def order_sql(statement): + from sqlalchemy.dialects import postgresql + + sql = str(statement.compile(dialect=postgresql.dialect())) + return " ".join(sql.split("ORDER BY", 1)[1].split()) + + +def test_asc_and_desc_on_an_order_column_sort_by_its_order_term(scores, untouched): + assert order_sql(select(Score).order_by(Score.level.asc())) == "eql_v4.ord_term(scores.level) ASC" + assert order_sql(select(Score).order_by(Score.level.desc().nulls_last())) == ( + "eql_v4.ord_term(scores.level) DESC NULLS LAST" + ) + + +def test_sorting_a_column_with_no_order_index_is_refused(scores, untouched): + with pytest.raises(QueryError) as raised: + Score.note.asc() + assert raised.value.code == "query_unsupported" + assert "order index" in raised.value.message + + +def test_a_bare_order_by_is_sorted_by_the_order_term_in_a_session(untouched): + from sqlalchemy import asc + from sqlalchemy.orm import Session + + @stackencrypt.model("laps") + class Lap(Base): + __tablename__ = "laps" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + seconds: Mapped[Annotated[int, IntegerOrd]] + note: Mapped[Annotated[str, Text]] + + engine = create_engine("sqlite://") + seen = [] + from sqlalchemy import event + + @event.listens_for(engine, "before_cursor_execute") + def capture(conn, cursor, statement, parameters, context, executemany): + seen.append(statement) + + with engine.begin() as conn: + conn.exec_driver_sql("CREATE TABLE laps (id INTEGER PRIMARY KEY, seconds TEXT, note TEXT)") + configure_encryption([Lap]) + + with Session(engine) as session: + for statement in (select(Lap).order_by(Lap.seconds), select(Lap).order_by(asc(Lap.seconds))): + try: + session.execute(statement) + except Exception: + pass # SQLite has no eql_v4 functions: only the SQL matters here. + with pytest.raises(QueryError) as raised: + session.execute(select(Lap).order_by(Lap.note)) + assert all("ORDER BY eql_v4.ord_term(laps.seconds)" in sql for sql in seen[-2:]), seen + assert raised.value.code == "query_unsupported" + + +# A comparison with a SQL expression, not a Python value. + + +@pytest.mark.parametrize( + "build", + [ + lambda: Score.note == literal("secret"), + lambda: Score.note == func.lower("secret"), + lambda: Score.level > literal(3) + 1, + ], + ids=["literal", "function-of-a-value", "expression-of-a-value"], +) +def test_a_comparison_with_an_expression_that_holds_a_value_is_refused(scores, untouched, build): + with pytest.raises(QueryError) as raised: + build() + assert raised.value.code == "query_unsupported" + assert "secret" not in str(raised.value) + + +def test_a_comparison_with_another_column_sends_no_value(scores, untouched): + from sqlalchemy.dialects import postgresql + + sql = str((Score.note == Score.note).compile(dialect=postgresql.dialect())) + + assert sql == "scores.note = scores.note" + + +def test_a_labelled_column_in_order_by_is_sorted_by_the_order_term(untouched): + from sqlalchemy import event + from sqlalchemy.orm import Session + + @stackencrypt.model("splits") + class Split(Base): + __tablename__ = "splits" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + seconds: Mapped[Annotated[int, IntegerOrd]] + + engine = create_engine("sqlite://") + sent = [] + event.listen(engine, "before_cursor_execute", lambda conn, cur, sql, *a: sent.append(sql)) + with engine.begin() as conn: + conn.exec_driver_sql("CREATE TABLE splits (id INTEGER PRIMARY KEY, seconds TEXT)") + configure_encryption([Split]) + secs = Split.seconds.label("secs") + + with Session(engine) as session: + for statement in ( + select(secs).order_by(secs), + select(secs).order_by("secs"), + select(secs).order_by(secs.desc()), + ): + try: + session.execute(statement) + except Exception: + pass # SQLite has no eql_v4 functions: only the SQL matters here. + + sorts = [sql.split("ORDER BY", 1)[1].strip() for sql in sent[-3:]] + assert sorts == [ + "eql_v4.ord_term(splits.seconds)", + "eql_v4.ord_term(splits.seconds)", + "eql_v4.ord_term(splits.seconds) DESC", + ] + + +def test_a_column_named_by_a_string_in_order_by_is_sorted_by_the_order_term(untouched): + from sqlalchemy import desc, event + from sqlalchemy.orm import Session + + @stackencrypt.model("legs") + class Leg(Base): + __tablename__ = "legs" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + seconds: Mapped[Annotated[int, IntegerOrd]] + + engine = create_engine("sqlite://") + sent = [] + event.listen(engine, "before_cursor_execute", lambda conn, cur, sql, *a: sent.append(sql)) + with engine.begin() as conn: + conn.exec_driver_sql("CREATE TABLE legs (id INTEGER PRIMARY KEY, seconds TEXT)") + configure_encryption([Leg]) + statements = [ + select(Leg.seconds).order_by("seconds"), + select(Leg.seconds).order_by(desc("seconds")), + select(Leg).order_by("seconds"), + select(Leg.__table__).order_by("seconds"), + ] + + with Session(engine) as session: + for statement in statements: + try: + session.execute(statement) + except Exception: + pass # SQLite has no eql_v4 functions: only the SQL matters here. + + sorts = [sql.split("ORDER BY", 1)[1].strip() for sql in sent[-4:]] + assert sorts == [ + "eql_v4.ord_term(legs.seconds)", + "eql_v4.ord_term(legs.seconds) DESC", + "eql_v4.ord_term(legs.seconds)", + "eql_v4.ord_term(legs.seconds)", + ] + + +def test_a_string_naming_an_encrypted_column_that_is_not_selected_is_sorted_or_refused(untouched): + from sqlalchemy import event + from sqlalchemy.orm import Session, aliased + + @stackencrypt.model("stages") + class Stage(Base): + __tablename__ = "stages" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + name: Mapped[Annotated[str, Passthrough]] + seconds: Mapped[Annotated[int, IntegerOrd]] + + engine = create_engine("sqlite://") + sent = [] + event.listen(engine, "before_cursor_execute", lambda conn, cur, sql, *a: sent.append(sql)) + with engine.begin() as conn: + conn.exec_driver_sql("CREATE TABLE stages (id INTEGER PRIMARY KEY, name TEXT, seconds TEXT)") + configure_encryption([Stage]) + other = aliased(Stage) + + with Session(engine) as session: + try: + session.execute(select(Stage.name).order_by("seconds")) + except Exception: + pass # SQLite has no eql_v4 functions: only the SQL matters here. + with pytest.raises(QueryError) as raised: + session.execute(select(Stage.name, other.name).join(other, other.id == Stage.id).order_by("seconds")) + + assert sent[-1].split("ORDER BY", 1)[1].strip() == "eql_v4.ord_term(stages.seconds)" + assert raised.value.code == "query_unsupported" + assert "seconds" in raised.value.message + + +def test_a_plaintext_label_with_an_encrypted_columns_name_is_left_alone(untouched): + from sqlalchemy import event + from sqlalchemy.orm import Session + + @stackencrypt.model("heats") + class Heat(Base): + __tablename__ = "heats" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + rank: Mapped[Annotated[int, Passthrough]] + seconds: Mapped[Annotated[int, IntegerOrd]] + + engine = create_engine("sqlite://") + sent = [] + event.listen(engine, "before_cursor_execute", lambda conn, cur, sql, *a: sent.append(sql)) + with engine.begin() as conn: + conn.exec_driver_sql("CREATE TABLE heats (id INTEGER PRIMARY KEY, rank INTEGER, seconds TEXT)") + configure_encryption([Heat]) + + with Session(engine) as session: + # The label "seconds" names the plaintext rank, so the sort is by rank. + session.execute(select(Heat.rank.label("seconds")).order_by("seconds")) + try: + session.execute(select(Heat.rank, Heat.seconds.label("rank")).order_by("rank")) + except Exception: + pass # SQLite has no eql_v4 functions: only the SQL matters here. + + assert sent[-2].split("ORDER BY", 1)[1].strip() == "seconds" + # Here the label "rank" names the encrypted column, so the sort is its term. + assert sent[-1].split("ORDER BY", 1)[1].strip() == "eql_v4.ord_term(heats.seconds)" + + +# A sealed value the statement carries itself, in any form the hook does not +# encrypt. + +SECRET = "secret@example.com" + + +@stackencrypt.model("inline_leads") +class InlineLead(Base): + __tablename__ = "inline_leads" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] + + +@pytest.fixture +def leads(credentials): + """An SQLite engine with a configured ``inline_leads`` table, and its class.""" + Lead = InlineLead + engine = create_engine("sqlite://") + with engine.begin() as conn: + conn.exec_driver_sql("CREATE TABLE inline_leads (id INTEGER PRIMARY KEY, email TEXT)") + configure_encryption([Lead]) + return engine, Lead + + +def _inline_writes(Lead): + from sqlalchemy import bindparam, case, insert, update + from sqlalchemy.dialects import sqlite + + upsert = sqlite.insert(Lead).values(id=1, email=None) + return { + "multi-row values": (insert(Lead).values([{"email": SECRET}, {"email": SECRET}]), None), + "a function of a value": (insert(Lead).values(email=func.lower(SECRET)), None), + "a bindparam of another name": ( + insert(Lead).values(email=bindparam("new_email")), + [{"new_email": SECRET}, {"new_email": SECRET}], + ), + "a case with a value": ( + update(Lead).values(email=case((Lead.id == 1, SECRET), else_=SECRET)), + None, + ), + "an on-conflict set": ( + upsert.on_conflict_do_update(index_elements=["id"], set_={"email": SECRET}), + None, + ), + } + + +@pytest.mark.parametrize( + "form", + ["multi-row values", "a function of a value", "a bindparam of another name", "a case with a value", "an on-conflict set"], +) +def test_a_sealed_value_in_any_form_of_the_statement_is_refused(leads, untouched, form): + engine, Lead = leads + statement, rows = _inline_writes(Lead)[form] + with engine.begin() as conn, pytest.raises(DeclarationError) as raised: + conn.execute(statement, rows) if rows else conn.execute(statement) + assert raised.value.code == "declaration_inline_value" + assert raised.value.field == "email" + assert SECRET not in str(raised.value) + with engine.connect() as conn: + assert conn.execute(select(Lead.__table__)).all() == [] + + +def test_an_on_conflict_set_from_the_excluded_row_is_encrypted_not_refused(leads): + from sqlalchemy.dialects import sqlite + + engine, Lead = leads + statement = sqlite.insert(Lead) + statement = statement.on_conflict_do_update( + index_elements=["id"], set_={"email": statement.excluded.email} + ) + # Past the refusal, the hook encrypts the row: with no client key, that + # fails building the client, before the database. + with engine.begin() as conn, pytest.raises(StashError) as raised: + conn.execute(statement, [{"id": 1, "email": SECRET}]) + assert not isinstance(raised.value, DeclarationError) + + +# The session hook reads rows as the app asks for them. + + +class Shelf(Base): + __tablename__ = "plain_shelves" + id: Mapped[int] = mapped_column(primary_key=True) + boxes: Mapped[list["Box"]] = relationship() + + +class Box(Base): + __tablename__ = "plain_boxes" + id: Mapped[int] = mapped_column(primary_key=True) + shelf_id: Mapped[int] = mapped_column(ForeignKey("plain_shelves.id")) + + +@pytest.fixture +def shelves(untouched): + """Two shelves of two boxes in SQLite, with the hooks installed.""" + from sqlalchemy.orm import Session + + configure_encryption([Customer]) # installs the hooks, if no test did + engine = create_engine("sqlite://") + Base.metadata.create_all(engine, tables=[Shelf.__table__, Box.__table__]) + with Session(engine) as session: + session.add_all([Shelf(id=1, boxes=[Box(id=1), Box(id=2)]), Shelf(id=2, boxes=[Box(id=3), Box(id=4)])]) + session.commit() + return engine + + +def test_a_joined_eager_load_of_two_entities_with_unique_works(shelves): + from sqlalchemy.orm import Session, joinedload + + with Session(shelves) as session: + rows = ( + session.execute(select(Shelf, Box).join(Shelf.boxes).options(joinedload(Shelf.boxes))) + .unique() + .all() + ) + assert len(rows) == 4 + + +def test_a_joined_eager_load_with_no_unique_is_still_refused_by_sqlalchemy(shelves): + from sqlalchemy.exc import InvalidRequestError + from sqlalchemy.orm import Session, joinedload + + with Session(shelves) as session, pytest.raises(InvalidRequestError, match="unique"): + session.execute(select(Shelf).options(joinedload(Shelf.boxes))).all() + + +def test_yield_per_reads_the_rows_a_chunk_at_a_time(shelves): + from sqlalchemy import event + from sqlalchemy.orm import Session + + loaded = [] + listener = lambda target, context: loaded.append(target.id) # noqa: E731 + event.listen(Box, "load", listener) + try: + with Session(shelves) as session: + result = session.execute(select(Box).order_by(Box.id).execution_options(yield_per=1)) + first = next(iter(result.scalars())) + assert first.id == 1 + assert loaded == [1] + result.close() + finally: + event.remove(Box, "load", listener) + + +# A single-table subclass of a configured class. + + +@stackencrypt.model("members_plus") +class MemberPlus(Base): + __tablename__ = "members_plus" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + kind: Mapped[Annotated[str, Passthrough]] + email: Mapped[Annotated[str, TextEq]] + __mapper_args__ = {"polymorphic_on": "kind", "polymorphic_identity": "member"} + + +class GoldMember(MemberPlus): + __mapper_args__ = {"polymorphic_identity": "gold"} + + +def test_a_subclass_on_its_bases_table_is_accepted_when_its_base_is_configured(untouched): + configure_encryption([MemberPlus]) + configure_encryption([GoldMember]) + configure_encryption([MemberPlus, GoldMember]) + + +def test_a_subclass_load_is_decrypted_like_its_base(untouched): + from sqlalchemy.orm import Session + + engine = create_engine("sqlite://") + with engine.begin() as conn: + conn.exec_driver_sql("CREATE TABLE members_plus (id INTEGER PRIMARY KEY, kind TEXT, email TEXT)") + conn.exec_driver_sql("""INSERT INTO members_plus VALUES (1, 'gold', '{"i": {"t": "members_plus", "c": "email"}, "v": 2, "c": "x"}')""") + configure_encryption([MemberPlus]) + # The hook tries to decrypt the stored value: with no client key, that + # fails, instead of returning the stored JSON as the email. + with Session(engine) as session, pytest.raises(StashError): + session.execute(select(GoldMember)).scalars().all() + + +# Unique indexes and primary keys on an encrypted column. + + +def test_an_encrypted_column_in_a_unique_index_is_refused(engine): + from sqlalchemy import Index + + @stackencrypt.model("tags") + class Tag(Base): + __tablename__ = "tags" + __table_args__ = (Index("ux_tags_label", "label", unique=True),) + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + label: Mapped[Annotated[str, TextEq]] + + error = refused([Tag]) + assert error.code == "declaration_unique_column" + assert error.field == "label" + + +def test_an_encrypted_primary_key_is_refused(engine): + @stackencrypt.model("badges") + class Badge(Base): + __tablename__ = "badges" + code: Mapped[Annotated[str, TextEq]] = mapped_column(primary_key=True) + + error = refused([Badge]) + assert error.code == "declaration_unique_column" + assert error.field == "code" + + +# ORDER BY outside a select's own ORDER BY. + + +@pytest.mark.parametrize( + "build", + [ + lambda: select(Score.id).union(select(Score.id)).order_by(Score.level), + lambda: select(func.row_number().over(order_by=Score.level)), + lambda: select(func.percentile_disc(0.5).within_group(Score.level)), + lambda: select(_aggregate_order_by(func.array_agg(Score.id), Score.level)), + ], + ids=["union", "over", "within_group", "aggregate_order_by"], +) +def test_a_bare_encrypted_column_sorted_where_the_hooks_cannot_rewrite_it_is_refused(scores, untouched, build): + with create_engine("sqlite://").connect() as conn, pytest.raises(QueryError) as raised: + conn.execute(build()) + assert raised.value.code == "query_unsupported" + assert raised.value.reason == "order" + + +def test_a_window_sorted_by_asc_of_an_encrypted_column_uses_its_order_term(scores, untouched): + statement = select(func.row_number().over(order_by=Score.level.asc())) + from sqlalchemy.dialects import postgresql + + assert "eql_v4.ord_term(scores.level)" in str(statement.compile(dialect=postgresql.dialect())) + + +def _aggregate_order_by(target, order_by): + from sqlalchemy.dialects.postgresql import aggregate_order_by + + return aggregate_order_by(target, order_by) + + +# Index names. + + +def test_an_index_name_longer_than_postgres_allows_is_shortened(): + from sqlalchemy.dialects import postgresql + from sqlalchemy.schema import CreateIndex + + class Event(Base): + __tablename__ = "customer_subscription_events" + id: Mapped[int] = mapped_column(primary_key=True) + billing_contact_email_address: Mapped[Annotated[str, TextEq]] + + names = [index.name for index in Event.__table__.indexes] + assert len(names) == 1 + assert len(names[0]) <= 63 + for index in Event.__table__.indexes: + CreateIndex(index).compile(dialect=postgresql.dialect()) + + +def test_a_mixed_case_columns_own_index_on_its_term_is_kept_alone(): + from sqlalchemy import Index, text + + class Contact(Base): + __tablename__ = "mixed_contacts" + __table_args__ = (Index("ux_mixed_email", text('eql_v4.eq_term("Email")'), unique=True),) + id: Mapped[int] = mapped_column(primary_key=True) + Email: Mapped[Annotated[str, TextEq]] + + assert [index.name for index in Contact.__table__.indexes] == ["ux_mixed_email"] diff --git a/languages/python/tests/test_sqlalchemy_live.py b/languages/python/tests/test_sqlalchemy_live.py new file mode 100644 index 0000000000..8f56942e0a --- /dev/null +++ b/languages/python/tests/test_sqlalchemy_live.py @@ -0,0 +1,749 @@ +"""``configure_encryption`` against ZeroKMS and a PostgreSQL with the EQL v4 +bundle: the app writes and reads plaintext attributes, and the tables hold +EQL values. + +These tests need the CipherStash credentials and ``EQL_TEST_DATABASE_URL``, +as ``test_encrypt_live.py`` does. Each test makes its own tables in a schema +of its own, and drops the schema afterwards. +""" + +import decimal +import uuid +from typing import Annotated, List, Optional + +import pytest +from _live import LIVE, needs_database, sqlalchemy_url +from sqlalchemy import ForeignKey, and_, create_engine, delete, desc, func, not_, or_, select, text, update +from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship + +import stackencrypt +from stackencrypt import Ignore, IntegerOrd, MatchQuery, NumericOrd, Passthrough, QueryError, Text, TextEq, TextMatch, _engine +from stackencrypt.sqlalchemy import configure_encryption, eql_type_map + +pytestmark = [*LIVE, needs_database] + + +class Base(DeclarativeBase): + type_annotation_map = eql_type_map + + +@stackencrypt.model("customers") +class Customer(Base): + __tablename__ = "customers" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] + name: Mapped[Annotated[str, Text]] + plan: Mapped[Annotated[str, Passthrough]] + # A relationship needs no marker: it is never a column. + orders: Mapped[List["Order"]] = relationship(back_populates="customer") + + +@stackencrypt.model("orders") +class Order(Base): + __tablename__ = "orders" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + customer_id: Mapped[Annotated[int, Ignore]] = mapped_column(ForeignKey("customers.id")) + note: Mapped[Annotated[str, Text]] + customer: Mapped["Customer"] = relationship(back_populates="orders") + + +@stackencrypt.model("profiles") +class Profile(Base): + __tablename__ = "profiles" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + bio: Mapped[Annotated[str, TextMatch]] + plan: Mapped[Annotated[str, Passthrough]] + + +@stackencrypt.model("venues") +class Venue(Base): + __tablename__ = "venues" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + name: Mapped[Annotated[str, TextEq]] + city: Mapped[Annotated[Optional[str], TextEq]] + visits: Mapped[Annotated[Optional[int], Passthrough]] + + +@stackencrypt.model("trips") +class Trip(Base): + __tablename__ = "trips" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + name: Mapped[Annotated[str, TextMatch]] + town: Mapped[Annotated[str, TextMatch]] + city: Mapped[Annotated[Optional[str], TextMatch]] + + +@stackencrypt.model("guides") +class Guide(Base): + __tablename__ = "guides" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + trip_id: Mapped[Annotated[int, Ignore]] = mapped_column(ForeignKey("trips.id")) + bio: Mapped[Annotated[str, TextMatch]] + + +@stackencrypt.model("runs") +class Run(Base): + __tablename__ = "runs" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + label: Mapped[Annotated[str, Passthrough]] + average_hr: Mapped[Annotated[Optional[int], IntegerOrd]] + pace: Mapped[Annotated[Optional[decimal.Decimal], NumericOrd]] + + +@stackencrypt.model("players") +class Player(Base): + __tablename__ = "players" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + level: Mapped[Annotated[int, IntegerOrd]] + + +@stackencrypt.model("members") +class Member(Base): + __tablename__ = "members" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + kind: Mapped[Annotated[str, Passthrough]] + email: Mapped[Annotated[str, TextEq]] + __mapper_args__ = {"polymorphic_on": "kind", "polymorphic_identity": "member"} + + +class GoldMember(Member): + """Single-table inheritance: stored in ``members``, with no declaration.""" + + __mapper_args__ = {"polymorphic_identity": "gold"} + + +@pytest.fixture(scope="module") +def engine(): + schema = f"sa_{uuid.uuid4().hex[:12]}" + # The EQL types are in `public` and the functions in `eql_v4`, so both + # stay on the search path after the test schema. + engine = create_engine( + sqlalchemy_url(), + connect_args={"options": f"-c search_path={schema},public"}, + hide_parameters=True, + ) + with engine.begin() as conn: + conn.execute(text(f"CREATE SCHEMA {schema}")) + Base.metadata.create_all(engine) + configure_encryption([Customer, Order, Player, Profile, Venue, Trip, Guide, Run, Member, GoldMember]) + yield engine + with engine.begin() as conn: + conn.execute(text(f"DROP SCHEMA {schema} CASCADE")) + engine.dispose() + + +@pytest.fixture(autouse=True) +def empty(engine): + with engine.begin() as conn: + conn.execute(text("TRUNCATE orders, customers, players, profiles, venues, guides, trips, runs, members RESTART IDENTITY")) + + +def stored(engine, sql): + with engine.connect() as conn: + return conn.execute(text(sql)).all() + + +def add_ann(engine): + with Session(engine) as session: + ann = Customer(email="ann@example.com", name="Ann", plan="free") + ann.orders = [Order(note="first order"), Order(note="second order")] + session.add(ann) + session.commit() + + +def test_an_added_object_is_stored_encrypted_and_keeps_its_plaintext(engine): + with Session(engine, expire_on_commit=False) as session: + ann = Customer(email="ann@example.com", name="Ann", plan="free") + session.add(ann) + session.commit() + + assert (ann.email, ann.name, ann.plan) == ("ann@example.com", "Ann", "free") + assert not session.dirty + [(email, name, plan)] = stored(engine, "SELECT email::text, name::text, plan FROM customers") + assert "ann@example.com" not in email + assert "Ann" not in name + assert '"c"' in email + assert plan == "free" + + +def test_a_query_decrypts_the_objects_it_loads(engine): + add_ann(engine) + with Session(engine) as session: + [ann] = session.scalars(select(Customer)).all() + + assert (ann.email, ann.name, ann.plan) == ("ann@example.com", "Ann", "free") + assert not session.dirty + + +def test_an_update_of_one_field_is_stored_encrypted(engine): + add_ann(engine) + with Session(engine) as session: + ann = session.scalars(select(Customer)).one() + ann.email = "ann@new.example.com" + session.commit() + [(email,)] = stored(engine, "SELECT email::text FROM customers") + assert "new.example.com" not in email + with Session(engine) as session: + ann = session.scalars(select(Customer)).one() + assert (ann.email, ann.name) == ("ann@new.example.com", "Ann") + + +def test_a_relationship_load_decrypts_the_related_objects(engine): + add_ann(engine) + with Session(engine) as session: + ann = session.scalars(select(Customer)).one() + assert sorted(order.note for order in ann.orders) == ["first order", "second order"] + + +def test_a_joined_eager_load_decrypts_both_classes(engine): + from sqlalchemy.orm import joinedload + + add_ann(engine) + with Session(engine) as session: + rows = ( + session.execute(select(Customer, Order).join(Customer.orders).options(joinedload(Customer.orders))) + .unique() + .all() + ) + assert len(rows) == 2 + assert {customer.email for customer, _ in rows} == {"ann@example.com"} + assert sorted(order.note for _, order in rows) == ["first order", "second order"] + + +def test_yield_per_decrypts_every_chunk(engine): + with Session(engine) as session: + session.add_all([Customer(email=f"c{i}@example.com", name=f"C{i}", plan="free") for i in range(5)]) + session.commit() + with Session(engine) as session: + result = session.scalars(select(Customer).order_by(Customer.id).execution_options(yield_per=2)) + assert [customer.email for customer in result] == [f"c{i}@example.com" for i in range(5)] + + +def test_a_single_table_subclass_is_encrypted_and_decrypted_as_its_base(engine): + with Session(engine) as session: + session.add(GoldMember(email="gold@example.com")) + session.commit() + [(kind, email)] = stored(engine, "SELECT kind, email::text FROM members") + assert kind == "gold" + assert "gold@example.com" not in email + with Session(engine) as session: + gold = session.scalars(select(GoldMember).where(GoldMember.email == "gold@example.com")).one() + assert gold.email == "gold@example.com" + with Session(engine) as session: + [member] = session.scalars(select(Member)).all() + assert isinstance(member, GoldMember) + assert member.email == "gold@example.com" + + +def test_an_attribute_read_after_commit_is_decrypted(engine): + add_ann(engine) + with Session(engine) as session: + ann = session.scalars(select(Customer)).one() + ann.plan = "pro" + session.commit() # expires every attribute + + assert ann.email == "ann@example.com" + assert not session.dirty + + +def test_100_objects_make_one_key_request_to_insert_and_one_to_load(engine): + add_ann(engine) # warms up the client: the first call may load the keyset + with Session(engine) as session: + session.add_all( + [Customer(email=f"user{i}@example.com", name=f"User {i}", plan="free") for i in range(100)] + ) + before = _engine.request_count() + session.commit() + inserted = _engine.request_count() - before + with Session(engine) as session: + before = _engine.request_count() + customers = session.scalars(select(Customer).where(Customer.plan == "free")).all() + loaded = _engine.request_count() - before + + # 100 objects of 2 sealed fields: 200 values, one request of up to 500 keys. + assert (inserted, loaded) == (1, 1) + assert sorted(c.email for c in customers)[0] == "ann@example.com" + + +def test_an_equality_search_finds_the_one_matching_row(engine): + add_ann(engine) + with Session(engine) as session: + session.add(Customer(email="bob@example.com", name="Bob", plan="free")) + session.commit() + with Session(engine) as session: + found = session.scalars(select(Customer).where(Customer.email == "bob@example.com")).all() + + assert [(c.email, c.name) for c in found] == [("bob@example.com", "Bob")] + + +def test_order_searches_find_the_rows_on_each_side(engine): + with Session(engine) as session: + session.add_all([Player(level=level) for level in (1, 3, 5, 7)]) + session.commit() + with Session(engine) as session: + high = session.scalars(select(Player).where(Player.level >= 5)).all() + low = session.scalars(select(Player).where(Player.level < 3)).all() + middle = session.scalars( + select(Player).where(Player.level > 1, Player.level <= 5).order_by(Player.id) + ).all() + + assert sorted(p.level for p in high) == [5, 7] + assert [p.level for p in low] == [1] + assert [p.level for p in middle] == [3, 5] + + +def test_an_order_search_on_an_equality_field_is_unsupported(engine): + with pytest.raises(QueryError) as raised: + select(Customer).where(Customer.email > "a@example.com") + assert raised.value.code == "query_unsupported" + + +def test_not_equal_and_in_find_the_right_rows(engine): + with Session(engine) as session: + session.add_all( + [Customer(email=f"{name}@example.com", name=name, plan="free") for name in ("ann", "bob", "cy")] + ) + session.commit() + with Session(engine) as session: + def names(*where): + return sorted(c.name for c in session.scalars(select(Customer).where(*where))) + + assert names(Customer.email != "bob@example.com") == ["ann", "cy"] + assert names(Customer.email.in_(["ann@example.com", "cy@example.com"])) == ["ann", "cy"] + assert names(Customer.email.not_in(["ann@example.com"])) == ["bob", "cy"] + assert names(Customer.email.in_([])) == [] + + +def test_an_update_of_a_passthrough_field_only_makes_no_key_request(engine): + add_ann(engine) # warms up the client + with Session(engine) as session: + ann = session.scalars(select(Customer)).one() + ann.plan = "pro" + before = _engine.request_count() + session.commit() + assert _engine.request_count() - before == 0 + with Session(engine) as session: + assert session.scalars(select(Customer)).one().plan == "pro" + + +def test_a_query_of_columns_decrypts_the_encrypted_ones(engine): + add_ann(engine) + with Session(engine) as session: + rows = session.execute(select(Customer.email, Customer.name, Customer.plan)).all() + labelled = session.execute(select(Customer.email.label("address"))).all() + both = session.execute(select(Customer, Customer.name)).all() + + assert [tuple(row) for row in rows] == [("ann@example.com", "Ann", "free")] + assert [row.address for row in labelled] == ["ann@example.com"] + [(ann, name)] = both + assert (ann.email, name) == ("ann@example.com", "Ann") + + +def test_100_rows_of_columns_make_one_key_request(engine): + with Session(engine) as session: + session.add_all( + [Customer(email=f"user{i}@example.com", name=f"User {i}", plan="free") for i in range(100)] + ) + session.commit() + with Session(engine) as session: + before = _engine.request_count() + rows = session.execute(select(Customer.email, Customer.name)).all() + assert _engine.request_count() - before == 1 + assert sorted(row.email for row in rows)[0] == "user0@example.com" + + +def test_a_core_query_on_a_connection_returns_eql_values(engine): + add_ann(engine) + with engine.connect() as conn: + [email] = conn.execute(select(Customer.__table__.c.email)).scalars().all() + # Left to the app: cipher.decrypt_rows opens it. + assert "ann@example.com" not in str(email) + [opened] = stackencrypt.Cipher(Customer).decrypt_rows([{"email": email}]) + assert opened == {"email": "ann@example.com"} + + +# .match() on a TextMatch field. + +BIOS = ["Alice Smith", "Natalie Wood", "Bob Jones"] + + +def add_profiles(engine): + with Session(engine) as session: + session.add_all([Profile(bio=bio, plan="free") for bio in BIOS]) + session.commit() + + +def test_a_match_search_returns_the_rows_that_hold_the_text(engine): + add_profiles(engine) + with Session(engine) as session: + found = session.scalars(select(Profile).where(Profile.bio.match("ali"))).all() + + assert sorted(p.bio for p in found) == ["Alice Smith", "Natalie Wood"] + + +def test_rows_that_fail_verify_are_dropped(engine, monkeypatch): + add_profiles(engine) + verify = MatchQuery.verify + # A false match of the Bloom filter cannot be made on purpose. So + # verify refuses one row that the database returns. + monkeypatch.setattr( + MatchQuery, "verify", lambda q, values: [v != "Natalie Wood" and ok for v, ok in zip(values, verify(q, values))] + ) + with Session(engine) as session: + found = session.scalars( + select(Profile).where(Profile.bio.match("ali"), Profile.plan == "free") + ).all() + + assert [p.bio for p in found] == ["Alice Smith"] + + +def test_a_limit_can_return_fewer_rows_than_match(engine, monkeypatch): + add_profiles(engine) + verify = MatchQuery.verify + monkeypatch.setattr( + MatchQuery, "verify", lambda q, values: [v != "Alice Smith" and ok for v, ok in zip(values, verify(q, values))] + ) + with Session(engine) as session: + found = session.scalars( + select(Profile).where(Profile.bio.match("ali")).order_by(Profile.id).limit(1) + ).all() + + # The database's one row was Alice, which verify refused. Natalie also + # matches, but was past the limit. + assert found == [] + + +@pytest.mark.parametrize( + "statement", + [ + lambda: select(Profile).where(or_(Profile.bio.match("ali"), Profile.plan == "pro")), + lambda: select(Profile).where(not_(Profile.bio.match("ali"))), + lambda: select(Profile.plan).where(Profile.bio.match("ali")), + lambda: select(func.count()).select_from(Profile).where(Profile.bio.match("ali")), + lambda: update(Profile).where(Profile.bio.match("ali")).values(plan="pro"), + lambda: delete(Profile).where(Profile.bio.match("ali")), + ], + ids=["in-an-or", "in-a-not", "object-not-selected", "count", "update", "delete"], +) +def test_a_match_that_rows_cannot_be_checked_for_is_refused(engine, statement): + add_profiles(engine) + with Session(engine) as session, pytest.raises(QueryError) as raised: + session.execute(statement()) + assert raised.value.code == "query_unsupported" + assert "A match index can return rows" in raised.value.message + assert "ali" not in str(raised.value) + with Session(engine) as session: + assert sorted((p.bio, p.plan) for p in session.scalars(select(Profile))) == sorted( + (bio, "free") for bio in BIOS + ) + + +def test_a_match_on_a_connection_is_refused(engine): + add_profiles(engine) + with engine.connect() as conn, pytest.raises(QueryError) as raised: + conn.execute(select(Profile.__table__).where(Profile.bio.match("ali"))) + assert raised.value.code == "query_unsupported" + assert "is not run through a session" in raised.value.message + + +def test_a_match_on_a_field_with_no_match_index_is_unsupported(engine): + with pytest.raises(QueryError) as raised: + Customer.email.match("ali") + assert raised.value.code == "query_unsupported" + + +# A field declared Optional. + + +def test_an_optional_field_stores_null_reads_none_and_is_found_with_is_null(engine): + with Session(engine) as session: + session.add_all([Venue(name="hall", city=None, visits=None), Venue(name="park", city="Leeds", visits=2)]) + session.commit() + with Session(engine) as session: + missing = session.scalars(select(Venue).where(Venue.city.is_(None))).all() + leeds = session.scalars(select(Venue).where(Venue.city == "Leeds")).all() + cities = session.execute(select(Venue.name, Venue.city).order_by(Venue.id)).all() + + assert [(v.name, v.city, v.visits) for v in missing] == [("hall", None, None)] + assert [(v.name, v.city, v.visits) for v in leeds] == [("park", "Leeds", 2)] + assert [tuple(row) for row in cities] == [("hall", None), ("park", "Leeds")] + + +def test_an_optional_field_can_be_set_to_none_and_back(engine): + with Session(engine) as session: + session.add(Venue(name="hall", city="York", visits=1)) + session.commit() + with Session(engine) as session: + hall = session.scalars(select(Venue)).one() + hall.city = None + session.commit() + assert hall.city is None + with Session(engine) as session: + hall = session.scalars(select(Venue)).one() + assert hall.city is None + hall.city = "Bath" + session.commit() + with Session(engine) as session: + assert session.scalars(select(Venue).where(Venue.city == "Bath")).one().name == "hall" + + +# An or_ whose branches are all match searches. + +TRIPS = [ + ("Morning run", "Hove", "Brighton"), + ("Evening ride", "Leeds", None), + ("Hill walk", "Keswick", "Carlisle"), +] + + +def add_trips(engine): + with Session(engine) as session: + session.add_all([Trip(name=n, town=t, city=c) for n, t, c in TRIPS]) + session.commit() + + +def names(engine, *where): + with Session(engine) as session: + return sorted(t.name for t in session.scalars(select(Trip).where(*where))) + + +def any_field(text): + return or_(Trip.name.match(text), Trip.town.match(text), Trip.city.match(text)) + + +def test_an_or_of_matches_returns_the_rows_that_hold_the_text_in_any_field(engine): + add_trips(engine) + + assert names(engine, any_field("run")) == ["Morning run"] + assert names(engine, any_field("leeds")) == ["Evening ride"] + assert names(engine, any_field("brighton")) == ["Morning run"] + assert names(engine, any_field("hove")) == ["Morning run"] + + +def test_a_null_field_fails_its_branch_only(engine): + add_trips(engine) + + # Evening ride has no city; its town still matches. + assert names(engine, or_(Trip.city.match("leeds"), Trip.town.match("leeds"))) == ["Evening ride"] + + +def test_a_branch_can_be_an_and_of_matches(engine): + add_trips(engine) + + where = or_(and_(Trip.name.match("hill"), Trip.town.match("keswick")), Trip.city.match("brighton")) + assert names(engine, where) == ["Hill walk", "Morning run"] + + +def test_a_false_match_in_one_branch_is_removed_and_another_branch_keeps_the_row(engine, monkeypatch): + add_trips(engine) + verify = MatchQuery.verify + # A false match of the Bloom filter cannot be made on purpose. So verify + # refuses Hove as a town: Morning run must then be kept by its name. + monkeypatch.setattr( + MatchQuery, "verify", lambda q, values: [v != "Hove" and ok for v, ok in zip(values, verify(q, values))] + ) + + assert names(engine, or_(Trip.town.match("hove"), Trip.name.match("run"))) == ["Morning run"] + assert names(engine, or_(Trip.town.match("hove"), Trip.name.match("walk"))) == ["Hill walk"] + + +def test_two_or_groups_joined_with_and_keep_the_rows_that_pass_both(engine): + add_trips(engine) + + where = ( + or_(Trip.name.match("run"), Trip.name.match("ride")), + or_(Trip.town.match("leeds"), Trip.city.match("carlisle")), + ) + assert names(engine, *where) == ["Evening ride"] + + +@pytest.mark.parametrize( + "where", + [ + lambda: or_(Trip.name.match("run"), Trip.id == 1), + lambda: or_(not_(Trip.name.match("run")), Trip.town.match("hove")), + lambda: not_(or_(Trip.name.match("run"), Trip.town.match("hove"))), + lambda: or_(and_(Trip.name.match("run"), Trip.id == 1), Trip.town.match("hove")), + ], + ids=["mixed", "not-in-a-branch", "or-in-a-not", "mixed-and-in-a-branch"], +) +def test_an_or_that_is_not_only_matches_is_refused(engine, where): + add_trips(engine) + with Session(engine) as session, pytest.raises(QueryError) as raised: + session.execute(select(Trip).where(where())) + assert raised.value.code == "query_unsupported" + assert "A match index can return rows" in raised.value.message + + +def test_an_or_across_an_outer_join_fails_the_branch_of_a_missing_object(engine): + add_trips(engine) + with Session(engine) as session: + # Only Hill walk (id 3) has a guide. + session.add(Guide(trip_id=3, bio="Mountain leader")) + session.commit() + with Session(engine) as session: + statement = ( + select(Trip, Guide) + .outerjoin(Guide, Guide.trip_id == Trip.id) + .where(or_(Trip.name.match("run"), Guide.bio.match("mountain"))) + .order_by(Trip.id) + ) + found = [(trip.name, guide and guide.bio) for trip, guide in session.execute(statement)] + + # Morning run has no guide: its Guide branch fails, and its name keeps it. + assert found == [("Morning run", None), ("Hill walk", "Mountain leader")] + + +# ORDER BY an encrypted column with an order index. + +RUNS = [ + ("a", 150, decimal.Decimal("5.30")), + ("b", None, decimal.Decimal("4.75")), + ("c", 120, None), + ("d", 175, decimal.Decimal("6.10")), + ("e", 98, decimal.Decimal("4.80")), +] + + +def labels(engine, *order): + with Session(engine) as session: + return [r.label for r in session.scalars(select(Run).order_by(*order))] + + +@pytest.fixture +def runs(engine): + with Session(engine) as session: + session.add_all([Run(label=l, average_hr=hr, pace=p) for l, hr, p in RUNS]) + session.commit() + + +def test_order_by_an_integer_ord_column(engine, runs): + assert labels(engine, Run.average_hr.asc().nulls_last()) == ["e", "c", "a", "d", "b"] + assert labels(engine, Run.average_hr.desc().nulls_last()) == ["d", "a", "c", "e", "b"] + + +def test_order_by_a_numeric_ord_column(engine, runs): + assert labels(engine, Run.pace.asc().nulls_last()) == ["b", "e", "a", "d", "c"] + assert labels(engine, Run.pace.desc().nulls_last()) == ["d", "a", "e", "b", "c"] + + +def test_nulls_first_puts_the_missing_values_first(engine, runs): + assert labels(engine, Run.average_hr.asc().nulls_first())[0] == "b" + assert labels(engine, Run.pace.desc().nulls_first())[0] == "c" + + +def test_order_by_with_a_limit_pages_in_order(engine, runs): + with Session(engine) as session: + page = session.scalars( + select(Run).order_by(Run.average_hr.desc().nulls_last()).limit(2).offset(1) + ).all() + assert [r.label for r in page] == ["a", "c"] + + + +def test_a_bare_order_by_and_sqlalchemy_asc_sort_by_the_plaintext(engine, runs): + from sqlalchemy import asc, desc + + assert labels(engine, Run.average_hr) == ["e", "c", "a", "d", "b"] + assert labels(engine, asc(Run.pace)) == ["b", "e", "a", "d", "c"] + assert labels(engine, desc(Run.average_hr).nulls_last()) == ["d", "a", "c", "e", "b"] + + +def test_an_order_by_on_a_connection_sorts_by_the_plaintext(engine, runs): + table = Run.__table__ + with engine.connect() as conn: + found = conn.execute(select(table.c.label).order_by(table.c.average_hr.asc().nulls_last())).scalars().all() + bare = conn.execute(select(table.c.label).order_by(table.c.pace)).scalars().all() + + assert found == ["e", "c", "a", "d", "b"] + assert bare == ["b", "e", "a", "d", "c"] + + +def test_an_orm_select_on_a_connection_sorts_by_the_plaintext(engine, runs): + # select(Run.label) names a mapped attribute, so SQLAlchemy marks it as + # an ORM statement, even when it runs on a connection. + from sqlalchemy import asc + + with engine.connect() as conn: + bare = conn.execute(select(Run.label).order_by(Run.average_hr)).scalars().all() + function = conn.execute(select(Run.label).order_by(asc(Run.pace))).scalars().all() + + assert bare == ["e", "c", "a", "d", "b"] + assert function == ["b", "e", "a", "d", "c"] + + +@pytest.fixture +def sent(engine): + """The SQL the engine sends while a test runs. A wrong sort gives a + random order, which can be the right one by chance, so a sort test + also checks the SQL.""" + from sqlalchemy import event + + statements = [] + + def record(conn, cursor, statement, *args): + statements.append(statement) + + event.listen(engine, "before_cursor_execute", record) + yield statements + event.remove(engine, "before_cursor_execute", record) + + +def test_a_bare_order_by_on_an_aliased_class_sorts_by_the_plaintext(engine, runs, sent): + from sqlalchemy.orm import aliased + + other = aliased(Run) + found = labels_of(engine, select(other).order_by(other.average_hr)) + + assert found == ["e", "c", "a", "d", "b"] + assert "ORDER BY eql_v4.ord_term(runs_1.average_hr)" in sent[-1], sent[-1] + + +def test_a_bare_order_by_in_a_subquery_sorts_by_the_plaintext(engine, runs, sent): + # The three lowest heart rates, then their labels in the outer query. + lowest = select(Run.label, Run.average_hr).order_by(Run.average_hr).limit(3).subquery() + with Session(engine) as session: + found = session.execute(select(lowest.c.label)).scalars().all() + with engine.connect() as conn: + on_a_connection = conn.execute(select(lowest.c.label)).scalars().all() + + assert sorted(found) == ["a", "c", "e"] + assert sorted(on_a_connection) == ["a", "c", "e"] + assert all("ORDER BY eql_v4.ord_term(runs.average_hr)" in sql for sql in sent[-2:]), sent[-2:] + + +def labels_of(engine, statement): + with Session(engine) as session: + return [r.label for r in session.scalars(statement)] + + +def test_a_labelled_column_in_order_by_sorts_by_the_plaintext(engine, runs, sent): + hr = Run.average_hr.label("hr") + with Session(engine) as session: + by_label = session.execute(select(Run.label, hr).order_by(hr)).all() + by_name = session.execute(select(Run.label, hr).order_by("hr")).all() + + assert [row.label for row in by_label] == ["e", "c", "a", "d", "b"] + assert [row.label for row in by_name] == ["e", "c", "a", "d", "b"] + assert all("ORDER BY eql_v4.ord_term(runs.average_hr)" in sql for sql in sent[-2:]), sent[-2:] + # The selected column is still the decrypted value, under its label. + assert [row.hr for row in by_label] == [98, 120, 150, 175, None] + + +def test_a_column_named_by_a_string_in_order_by_sorts_by_the_plaintext(engine, runs, sent): + from sqlalchemy import desc + + assert labels_of(engine, select(Run).order_by("average_hr")) == ["e", "c", "a", "d", "b"] + assert labels_of(engine, select(Run).order_by(desc("pace").nulls_last())) == ["d", "a", "e", "b", "c"] + assert "ORDER BY eql_v4.ord_term(runs.average_hr)" in sent[-2], sent[-2] + assert "ORDER BY eql_v4.ord_term(runs.pace) DESC NULLS LAST" in sent[-1], sent[-1] + + +def test_a_plaintext_label_with_an_encrypted_columns_name_sorts_by_the_plaintext(engine, runs, sent): + # The label "average_hr" names the plaintext label column. + renamed = Run.label.label("average_hr") + with Session(engine) as session: + found = session.execute(select(renamed).order_by(desc("average_hr"))).scalars().all() + + assert found == ["e", "d", "c", "b", "a"] + assert "ord_term" not in sent[-1], sent[-1] diff --git a/languages/python/tests/test_sqlalchemy_migrations.py b/languages/python/tests/test_sqlalchemy_migrations.py new file mode 100644 index 0000000000..1e96a598fe --- /dev/null +++ b/languages/python/tests/test_sqlalchemy_migrations.py @@ -0,0 +1,331 @@ +"""The database indexes and the Alembic migration code that +``stackencrypt.sqlalchemy`` gives an encrypted column, with no database. + +``test_sqlalchemy_live.py`` runs a generated migration against PostgreSQL. +""" + +import datetime +from typing import Annotated + +import pytest +from alembic.autogenerate import render_python_code +from alembic.operations import ops +from alembic.autogenerate.api import AutogenContext +from alembic.runtime.migration import MigrationContext +from sqlalchemy import Column, Index, Integer, MetaData, String, Table, Text, text +from sqlalchemy.dialects import postgresql +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy.schema import CreateIndex + +import stackencrypt +from stackencrypt import Ignore, IntegerOrd, Passthrough, TextEq, TextSearch, _eql +from stackencrypt.sqlalchemy import ( + EqlType, + eql_type_map, + process_revision_directives, + render_item, +) + +# The index each kind of EQL index needs, as PostgreSQL prints it. +INDEX_SQL = { + "eq": "USING hash (eql_v4.eq_term({col}))", + "ope": "USING btree (eql_v4.ord_term({col}))", + "ore_block": "USING btree (eql_v4.ord_term_ore({col}))", + "match": "USING gin (eql_v4.match_term({col}))", +} + + +class Base(DeclarativeBase): + type_annotation_map = eql_type_map + + +@stackencrypt.model("people") +class Person(Base): + __tablename__ = "people" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] + bio: Mapped[Annotated[str, TextSearch]] + level: Mapped[Annotated[int, IntegerOrd]] + plan: Mapped[Annotated[str, Passthrough]] + + +def index_sql(index): + return " ".join(str(CreateIndex(index).compile(dialect=postgresql.dialect())).split()) + + +@pytest.mark.parametrize( + "marker", + [m for m in _eql.TYPES if m.producible and m.plaintext in ("string", "int32", "date")], + ids=lambda m: m.__name__, +) +def test_each_eql_index_of_a_marker_gets_its_database_index(marker): + python_type = {"string": str, "int32": int, "date": datetime.date}[marker.plaintext] + cls = type( + f"T{marker.__name__}", + (Base,), + { + "__tablename__": f"t_{marker.__name__.lower()}", + "__annotations__": { + "id": Mapped[int], + "v": Mapped[Annotated[python_type, marker]], + }, + "id": mapped_column(primary_key=True), + }, + ) + table = cls.__table__.name + + made = {index.name: index_sql(index) for index in cls.__table__.indexes} + + assert made == { + f"ix_{table}_v_{kind}": f"CREATE INDEX ix_{table}_v_{kind} ON {table} " + + INDEX_SQL[kind].format(col="v") + for kind in marker.indexes + } + + +def test_an_index_the_model_declares_on_the_same_term_replaces_the_sdk_index(): + @stackencrypt.model("members") + class Member(Base): + __tablename__ = "members" + # Unique, so a btree: PostgreSQL cannot make a hash index unique. + __table_args__ = (Index("members_email_unique", text("eql_v4.eq_term(email)"), unique=True),) + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextSearch]] + + assert sorted(index.name for index in Member.__table__.indexes) == [ + "ix_members_email_match", + "ix_members_email_ope", + "members_email_unique", + ] + + +def test_an_index_on_another_column_does_not_replace_the_sdk_index(): + @stackencrypt.model("guests") + class Guest(Base): + __tablename__ = "guests" + __table_args__ = (Index("guests_name_eq", text("eql_v4.eq_term(name)")),) + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] + name: Mapped[Annotated[str, TextEq]] + + assert sorted(index.name for index in Guest.__table__.indexes) == [ + "guests_name_eq", + "ix_guests_email_eq", + ] + + +def test_a_table_that_is_not_mapped_gets_no_index(): + # As Alembic's add_column builds one: its indexes would be created twice. + table = Table("t", MetaData(), Column("id", Integer, primary_key=True), Column("v", EqlType(TextEq))) + + assert table.indexes == set() + + +def test_a_model_gets_one_index_for_each_eql_index_of_each_encrypted_field(): + assert sorted(index.name for index in Person.__table__.indexes) == [ + "ix_people_bio_eq", + "ix_people_bio_match", + "ix_people_bio_ope", + "ix_people_email_eq", + "ix_people_level_ope", + ] + + +def migration_code(*operations): + context = MigrationContext.configure(dialect_name="postgresql") + return render_python_code( + ops.UpgradeOps(ops=list(operations)), render_item=render_item, migration_context=context + ) + + +def test_a_generated_migration_names_the_eql_type_by_its_module(): + code = migration_code(ops.CreateTableOp.from_table(Person.__table__)) + + assert "stackencrypt.sqlalchemy.EqlType(stackencrypt.TextEq)" in code + assert "stackencrypt.sqlalchemy.EqlType(stackencrypt.IntegerOrd)" in code + + +def test_render_item_adds_the_imports_the_type_needs(): + autogen = AutogenContext(MigrationContext.configure(dialect_name="postgresql")) + + render_item("type", EqlType(TextEq), autogen) + + assert {"import stackencrypt", "import stackencrypt.sqlalchemy"} <= autogen.imports + + +def test_render_item_names_a_marker_the_package_does_not_export_by_its_module(monkeypatch): + # A marker the engine cannot produce stays in _eql only. The catalog has + # none now, so Json stands in for one. + monkeypatch.delattr(stackencrypt, "Json", raising=False) + autogen = AutogenContext(MigrationContext.configure(dialect_name="postgresql")) + + rendered = render_item("type", EqlType(_eql.Json), autogen) + + assert rendered == "stackencrypt.sqlalchemy.EqlType(stackencrypt._eql.Json)" + namespace = {} + exec("import stackencrypt\nimport stackencrypt.sqlalchemy\nt = " + rendered, namespace) + assert namespace["t"].marker is _eql.Json + + +def test_render_item_leaves_other_items_to_alembic(): + autogen = AutogenContext(MigrationContext.configure(dialect_name="postgresql")) + + assert render_item("type", Integer(), autogen) is False + assert autogen.imports == set() + + +def test_a_generated_migration_creates_the_indexes(): + index = next(i for i in Person.__table__.indexes if i.name == "ix_people_email_eq") + + code = migration_code(ops.CreateIndexOp.from_index(index)) + + assert "op.create_index('ix_people_email_eq', 'people'" in code + assert "eql_v4.eq_term(email)" in code + assert "postgresql_using='hash'" in code + + +def test_the_generated_code_runs_with_those_imports(): + code = migration_code(ops.CreateTableOp.from_table(Person.__table__)) + calls = [] + namespace = {"op": type("Op", (), {"create_table": staticmethod(lambda *a, **k: calls.append(a))})} + exec("import sqlalchemy as sa\nimport stackencrypt\nimport stackencrypt.sqlalchemy\n", namespace) + + exec("def upgrade():\n" + code + "\nupgrade()\n", namespace) + + [(name, *columns)] = calls + assert name == "people" + email = next(c for c in columns if getattr(c, "name", None) == "email") + assert email.type.marker is TextEq + + +# process_revision_directives: a type change to an EQL type. + + +def script_with(*operations): + return ops.MigrationScript( + rev_id="1", + upgrade_ops=ops.UpgradeOps(ops=[ops.ModifyTableOps("accounts", list(operations))]), + downgrade_ops=ops.DowngradeOps(ops=[]), + ) + + +def rewrite(*operations): + script = script_with(*operations) + context = MigrationContext.configure(dialect_name="postgresql") + process_revision_directives(context, ("head",), [script]) + return script.upgrade_ops.ops[0].ops + + +def test_a_type_change_to_an_eql_type_becomes_an_alter_with_a_using_cast(): + [statement] = rewrite( + ops.AlterColumnOp("accounts", "email", existing_type=Text(), modify_type=EqlType(TextEq)) + ) + + assert isinstance(statement, ops.ExecuteSQLOp) + assert statement.sqltext == ( + "ALTER TABLE accounts ALTER COLUMN email TYPE public.eql_v4_text_eq " + "USING email::jsonb::public.eql_v4_text_eq" + ) + + +def test_the_table_schema_and_quoted_names_are_kept(): + script = ops.MigrationScript( + rev_id="1", + upgrade_ops=ops.UpgradeOps( + ops=[ + ops.ModifyTableOps( + "Accounts", + [ + ops.AlterColumnOp( + "Accounts", + "Email", + schema="app", + existing_type=Text(), + modify_type=EqlType(TextEq), + ) + ], + schema="app", + ) + ] + ), + downgrade_ops=ops.DowngradeOps(ops=[]), + ) + process_revision_directives(MigrationContext.configure(dialect_name="postgresql"), ("head",), [script]) + + [statement] = script.upgrade_ops.ops[0].ops + assert statement.sqltext == ( + 'ALTER TABLE app."Accounts" ALTER COLUMN "Email" TYPE public.eql_v4_text_eq ' + 'USING "Email"::jsonb::public.eql_v4_text_eq' + ) + + +def test_other_changes_of_the_same_column_are_kept(): + statement, alter = rewrite( + ops.AlterColumnOp( + "accounts", + "email", + existing_type=Text(), + modify_type=EqlType(TextEq), + existing_nullable=True, + modify_nullable=False, + ) + ) + + assert isinstance(statement, ops.ExecuteSQLOp) + assert alter.modify_type is None + assert alter.modify_nullable is False + + +def test_a_type_change_to_another_type_is_left_to_alembic(): + alter = ops.AlterColumnOp("accounts", "plan", existing_type=Text(), modify_type=String()) + + assert rewrite(alter) == [alter] + + +def test_the_rewritten_migration_renders_as_op_execute(): + script = script_with( + ops.AlterColumnOp("accounts", "email", existing_type=Text(), modify_type=EqlType(TextEq)) + ) + process_revision_directives(MigrationContext.configure(dialect_name="postgresql"), ("head",), [script]) + + code = render_python_code(script.upgrade_ops, render_item=render_item) + + assert "op.execute('ALTER TABLE accounts ALTER COLUMN email TYPE public.eql_v4_text_eq" in code + assert "alter_column" not in code + + +def test_a_type_change_between_the_same_eql_type_is_dropped_in_both_directions(): + # As the database gives the type of an EQL column back to Alembic. + reflected = postgresql.DOMAIN("eql_v4_text_eq", JSONB(), create_type=False) + script = ops.MigrationScript( + rev_id="1", + upgrade_ops=ops.UpgradeOps( + ops=[ + ops.ModifyTableOps( + "accounts", + [ops.AlterColumnOp("accounts", "email", existing_type=reflected, modify_type=EqlType(TextEq))], + ) + ] + ), + downgrade_ops=ops.DowngradeOps( + ops=[ + ops.ModifyTableOps( + "accounts", + [ops.AlterColumnOp("accounts", "email", existing_type=EqlType(TextEq), modify_type=reflected)], + ) + ] + ), + ) + + process_revision_directives(MigrationContext.configure(dialect_name="postgresql"), ("head",), [script]) + + assert script.upgrade_ops.is_empty() + assert script.downgrade_ops.is_empty() + + +def test_a_change_to_another_eql_type_is_kept(): + reflected = postgresql.DOMAIN("eql_v4_text", JSONB(), create_type=False) + alter = ops.AlterColumnOp("accounts", "email", existing_type=reflected, modify_type=EqlType(TextEq)) + + assert rewrite(alter) == [alter] diff --git a/languages/python/tests/test_sqlalchemy_migrations_live.py b/languages/python/tests/test_sqlalchemy_migrations_live.py new file mode 100644 index 0000000000..bcda4ad3a4 --- /dev/null +++ b/languages/python/tests/test_sqlalchemy_migrations_live.py @@ -0,0 +1,256 @@ +"""An Alembic migration of an existing table to encrypted columns, against +PostgreSQL with the EQL v4 bundle: ``alembic revision --autogenerate`` and +``alembic upgrade``, through an ``env.py`` that uses the two hooks of +``stackencrypt.sqlalchemy``. + +These tests need ``EQL_TEST_DATABASE_URL``, and the CipherStash credentials +for the searches. Each test makes its own schema, and drops it afterwards. +""" + +import pathlib +import sys +import textwrap +import uuid + +import pytest +from _live import LIVE, needs_database, sqlalchemy_url +from sqlalchemy import create_engine, text + +pytestmark = [*LIVE, needs_database] + +alembic_command = pytest.importorskip("alembic.command") +from alembic.config import Config # noqa: E402 + +MODELS = ''' +from typing import Annotated + +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +import stackencrypt +from stackencrypt import Ignore, IntegerOrd, Passthrough, TextEq, TextMatch +from stackencrypt.sqlalchemy import eql_type_map + + +class Base(DeclarativeBase): + type_annotation_map = eql_type_map + + +@stackencrypt.model("accounts") +class Account(Base): + __tablename__ = "accounts" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] # was text: changed in place + level: Mapped[Annotated[int, IntegerOrd]] # new + bio: Mapped[Annotated[str, TextMatch]] # new + plan: Mapped[Annotated[str, Passthrough]] # unchanged; `note` is removed +''' + +# Only `email` changes, so the USING cast is the first step that meets a row. +EMAIL_ONLY = ''' +from typing import Annotated + +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +import stackencrypt +from stackencrypt import Ignore, Passthrough, TextEq +from stackencrypt.sqlalchemy import eql_type_map + + +class Base(DeclarativeBase): + type_annotation_map = eql_type_map + + +@stackencrypt.model("accounts") +class Account(Base): + __tablename__ = "accounts" + id: Mapped[Annotated[int, Ignore]] = mapped_column(primary_key=True) + email: Mapped[Annotated[str, TextEq]] + note: Mapped[Annotated[str, Passthrough]] = mapped_column(nullable=True) + plan: Mapped[Annotated[str, Passthrough]] +''' + +ENV = ''' +from alembic import context +from sqlalchemy import create_engine + +from models import Base +from stackencrypt.sqlalchemy import process_revision_directives, render_item + +engine = create_engine(context.config.get_main_option("sqlalchemy.url")) +with engine.connect() as connection: + context.configure( + connection=connection, + target_metadata=Base.metadata, + render_item=render_item, + process_revision_directives=process_revision_directives, + ) + with context.begin_transaction(): + context.run_migrations() +''' + +SCRIPT = '''"""${message}""" +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} +''' + + +@pytest.fixture +def project(tmp_path, monkeypatch, request): + """An Alembic project in ``tmp_path`` with the models above, on a schema + of its own that holds the existing table. A test can name other models + with ``@pytest.mark.models(...)``.""" + marker = request.node.get_closest_marker("models") + models = marker.args[0] if marker else MODELS + schema = f"mig_{uuid.uuid4().hex[:12]}" + url = sqlalchemy_url(options=f"-csearch_path={schema},public").render_as_string(hide_password=False) + engine = create_engine(url) + with engine.begin() as conn: + conn.execute(text(f"CREATE SCHEMA {schema}")) + conn.execute( + text( + "CREATE TABLE accounts (id serial PRIMARY KEY, email text NOT NULL, " + "note text, plan varchar NOT NULL)" + ) + ) + (tmp_path / "versions").mkdir() + (tmp_path / "models.py").write_text(textwrap.dedent(models)) + (tmp_path / "env.py").write_text(textwrap.dedent(ENV)) + (tmp_path / "script.py.mako").write_text(SCRIPT) + monkeypatch.syspath_prepend(str(tmp_path)) + # env.py imports `models`: each test needs its own file, not the one an + # earlier test imported. + monkeypatch.delitem(sys.modules, "models", raising=False) + config = Config() + config.set_main_option("script_location", str(tmp_path)) + config.set_main_option("sqlalchemy.url", url.replace("%", "%%")) + yield config, engine + with engine.begin() as conn: + conn.execute(text(f"DROP SCHEMA {schema} CASCADE")) + engine.dispose() + + +def generate(config, message): + script = alembic_command.revision(config, message=message, autogenerate=True) + return pathlib.Path(script.path).read_text() + + +def body(migration, function): + """The lines of ``function`` in a migration file, comments left out.""" + lines = migration.split(f"def {function}():\n", 1)[1].split("\n\n\ndef ", 1)[0] + return [line.strip() for line in lines.splitlines() if line.strip() and not line.strip().startswith("#")] + + +def test_autogenerate_writes_the_columns_the_indexes_and_the_using_cast(project): + config, _ = project + + migration = generate(config, "encrypt accounts") + + assert "import stackencrypt.sqlalchemy" in migration + assert ( + "ALTER TABLE accounts ALTER COLUMN email TYPE public.eql_v4_text_eq " + "USING email::jsonb::public.eql_v4_text_eq" + ) in migration + assert "stackencrypt.sqlalchemy.EqlType(stackencrypt.IntegerOrd)" in migration + assert "stackencrypt.sqlalchemy.EqlType(stackencrypt.TextMatch)" in migration + assert "op.drop_column('accounts', 'note')" in migration + for index in ("ix_accounts_email_eq", "ix_accounts_level_ope", "ix_accounts_bio_match"): + assert f"op.create_index('{index}'" in migration + + +def test_the_upgrade_makes_the_columns_and_indexes_and_a_second_autogenerate_finds_nothing(project): + config, engine = project + generate(config, "encrypt accounts") + + alembic_command.upgrade(config, "head") + + with engine.connect() as conn: + columns = dict( + conn.execute( + text( + "SELECT attname, format_type(atttypid, atttypmod) FROM pg_attribute " + "WHERE attrelid = 'accounts'::regclass AND attnum > 0 AND NOT attisdropped" + ) + ).all() + ) + indexes = dict( + conn.execute( + text("SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'accounts'") + ).all() + ) + assert columns == { + "id": "integer", + "email": "eql_v4_text_eq", + "level": "eql_v4_integer_ord", + "bio": "eql_v4_text_match", + "plan": "character varying", + } + assert "USING hash (eql_v4.eq_term(email))" in indexes["ix_accounts_email_eq"] + assert "USING btree (eql_v4.ord_term(level))" in indexes["ix_accounts_level_ope"] + assert "USING gin (eql_v4.match_term(bio))" in indexes["ix_accounts_bio_match"] + + second = generate(config, "nothing to do") + assert body(second, "upgrade") == ["pass"] + assert body(second, "downgrade") == ["pass"] + + +@pytest.mark.models(EMAIL_ONLY) +def test_the_upgrade_refuses_a_column_with_plaintext_rows_and_keeps_them(project): + config, engine = project + with engine.begin() as conn: + conn.execute(text("INSERT INTO accounts (email, plan) VALUES ('a@example.com', 'free')")) + migration = generate(config, "encrypt email") + assert body(migration, "upgrade")[0].startswith("op.execute('ALTER TABLE accounts ALTER COLUMN email") + + with pytest.raises(Exception) as raised: + alembic_command.upgrade(config, "head") + + assert "invalid input syntax for type json" in str(raised.value) + with engine.connect() as conn: + assert conn.execute(text("SELECT email, plan FROM accounts")).all() == [("a@example.com", "free")] + + +def test_postgresql_uses_each_index_for_its_search(project): + config, engine = project + generate(config, "encrypt accounts") + alembic_command.upgrade(config, "head") + import models + + from stackencrypt import Cipher + + accounts = Cipher(models.Account) + searches = { + "ix_accounts_email_eq": ( + "email = CAST(:term AS eql_v4.query_text_eq)", + accounts.eq("email", "a@example.com"), + ), + "ix_accounts_level_ope": ( + "level >= CAST(:term AS eql_v4.query_integer_ord)", + accounts.gte("level", 3), + ), + "ix_accounts_bio_match": ( + "bio @@ CAST(:term AS eql_v4.query_text_match)", + accounts.matches("bio", "hello").term, + ), + } + with engine.begin() as conn: + # With no rows, PostgreSQL would read the table rather than an index. + conn.execute(text("SET LOCAL enable_seqscan = off")) + for index, (where, term) in searches.items(): + plan = conn.execute( + text(f"EXPLAIN SELECT id FROM accounts WHERE {where}"), {"term": term} + ).scalars().all() + assert any(index in line for line in plan), (index, plan) diff --git a/languages/python/tests/test_values.py b/languages/python/tests/test_values.py new file mode 100644 index 0000000000..0e7821a233 --- /dev/null +++ b/languages/python/tests/test_values.py @@ -0,0 +1,164 @@ +"""The value rules, through ``encrypt`` and ``eq`` alike. + +Every value is checked by exact type and range before any request, by one +function that both calls use. Each case here runs through both, and the +``untouched`` fixture fails a case that built the client or sent a request. + +A value that passes every check reaches the engine. With fake credentials +and no client key, the engine then fails to build its client: that failure +is a ``StashError`` that is not an ``InvalidValueError``, which shows the +checks passed. The round trip of each value is in ``test_encrypt_live.py``. +""" + +import dataclasses +import datetime +import decimal +import math +from typing import Annotated, Any, List, Tuple + +import pytest + +import stackencrypt +from stackencrypt import ( + BigintEq, + Boolean, + Cipher, + DateEq, + DeclarationError, + DoubleEq, + IntegerEq, + InvalidValueError, + NumericEq, + QueryError, + RealEq, + SmallintEq, + StashError, + TextEq, + TimestampEq, +) + +pytestmark = pytest.mark.usefixtures("credentials") + +UTC = datetime.timezone.utc +TEN_HOURS_AHEAD = datetime.timezone(datetime.timedelta(hours=10)) + +# The Python type each marker's field is declared with. +PYTHON_TYPE = { + SmallintEq: int, + IntegerEq: int, + BigintEq: int, + RealEq: float, + DoubleEq: float, + NumericEq: decimal.Decimal, + Boolean: bool, + DateEq: datetime.date, + TimestampEq: datetime.datetime, + TextEq: str, +} + + +def declared(marker: Any) -> Tuple[Cipher, Any]: + """A cipher for a class with one field ``x`` of ``marker``. Built + outside ``pytest.raises``, so a refused declaration fails the test.""" + cls = dataclasses.make_dataclass( + "Row", [("x", Annotated[PYTHON_TYPE[marker], marker])] + ) + return Cipher(stackencrypt.model("rows")(cls)), cls + + +def through_encrypt(cipher: Cipher, cls: Any, value: Any) -> None: + cipher.encrypt([cls(x=value)]) + + +def through_eq(cipher: Cipher, cls: Any, value: Any) -> None: + cipher.eq("x", value) + + +def calls(cases: List[Tuple[Any, ...]]) -> List[Any]: + """Each case through ``encrypt``, and through ``eq`` where the + marker has an ``eq`` index. ``Boolean`` has none.""" + return [ + pytest.param(call, *case, id=f"{call.__name__}-{case[0].__name__}-{case[1]!r}") + for case in cases + for call in (through_encrypt, through_eq) + if call is through_encrypt or "eq" in case[0].indexes + ] + + +# (marker, value, code, reason): one value per refusal the rules make. +REFUSED: List[Tuple[Any, Any, str, str]] = [ + (BigintEq, 2**63, "value_out_of_range", "int64"), + (BigintEq, -(2**63) - 1, "value_out_of_range", "int64"), + (IntegerEq, 2**31, "value_out_of_range", "int32"), + (IntegerEq, -(2**31) - 1, "value_out_of_range", "int32"), + (SmallintEq, 2**15, "value_out_of_range", "int16"), + (SmallintEq, -(2**15) - 1, "value_out_of_range", "int16"), + (RealEq, 0.1, "value_not_representable", "float32"), + (RealEq, 1e39, "value_not_representable", "float32"), + (NumericEq, decimal.Decimal("NaN"), "value_not_representable", "decimal"), + # One past the largest 96-bit mantissa, on each side. + (NumericEq, decimal.Decimal("79228162514264337593543950336"), "value_not_representable", "decimal"), + (NumericEq, decimal.Decimal("-79228162514264337593543950336"), "value_not_representable", "decimal"), + (NumericEq, decimal.Decimal("Infinity"), "value_not_representable", "decimal"), + (NumericEq, decimal.Decimal("1e-29"), "value_not_representable", "decimal"), + (TimestampEq, datetime.datetime(2024, 1, 1), "value_naive_datetime", "timestamp"), + (TextEq, None, "value_none", "string"), + (BigintEq, None, "value_none", "int64"), + (BigintEq, True, "value_wrong_type", "int64"), + (IntegerEq, 1.0, "value_wrong_type", "int32"), + (DoubleEq, 1, "value_wrong_type", "float64"), + (NumericEq, 1.5, "value_wrong_type", "decimal"), + (DateEq, datetime.datetime(2024, 1, 1, tzinfo=UTC), "value_wrong_type", "date"), + (TextEq, b"bytes", "value_wrong_type", "string"), + (Boolean, 1, "value_wrong_type", "bool"), +] + + +@pytest.mark.parametrize(("call", "marker", "value", "code", "reason"), calls(REFUSED)) +def test_a_value_that_does_not_fit_is_refused_before_any_request( + untouched, call, marker, value, code, reason +): + cipher, cls = declared(marker) + with pytest.raises(InvalidValueError) as raised: + call(cipher, cls, value) + + assert raised.value.code == code + assert raised.value.field == "x" + assert raised.value.reason == reason + + +# (marker, value): values at the edge of what each rule accepts. +ACCEPTED: List[Tuple[Any, Any]] = [ + (BigintEq, 2**63 - 1), + (BigintEq, -(2**63)), + (IntegerEq, 2**31 - 1), + (IntegerEq, -(2**31)), + (SmallintEq, 2**15 - 1), + (SmallintEq, -(2**15)), + (RealEq, 0.5), + (RealEq, math.nan), + (RealEq, math.inf), + (RealEq, -math.inf), + (RealEq, -0.0), + (DoubleEq, 0.1), + (DoubleEq, math.nan), + (DoubleEq, -math.inf), + (NumericEq, decimal.Decimal("-79228162514264337593543950335")), + (NumericEq, decimal.Decimal("1e-28")), + (Boolean, False), + (DateEq, datetime.date(1, 1, 1)), + (TimestampEq, datetime.datetime(9999, 12, 31, 23, 59, 59, 999999, tzinfo=UTC)), + (TimestampEq, datetime.datetime(2024, 1, 1, tzinfo=TEN_HOURS_AHEAD)), + (TextEq, ""), +] + + +@pytest.mark.parametrize(("call", "marker", "value"), calls(ACCEPTED)) +def test_a_value_at_the_edge_passes_every_check(call, marker, value): + cipher, cls = declared(marker) + with pytest.raises(StashError) as raised: + call(cipher, cls, value) + + # Only the failure to build the client shows that every check passed. + refused = (InvalidValueError, QueryError, DeclarationError) + assert not isinstance(raised.value, refused), raised.value diff --git a/languages/python/tests_deterministic/conftest.py b/languages/python/tests_deterministic/conftest.py new file mode 100644 index 0000000000..0c10c90002 --- /dev/null +++ b/languages/python/tests_deterministic/conftest.py @@ -0,0 +1,40 @@ +"""Every test here runs against the `deterministic-kms` test build. + +`mise run test:deterministic` builds the extension with that feature and runs +this folder in its own pytest process. The session switches the process to +the deterministic client before any test runs: its keys come from a seed +through stack-kms's `DeterministicSource`, not from ZeroKMS. So no test here +needs credentials or makes a request. + +The seed is the one the Go SDK's frozen rows were sealed under, so rows the +Go SDK stored open here. The switch is for the whole process, which is why +this folder is not under `tests/`. +""" + +import json +import os +from pathlib import Path + +import pytest + +from stackencrypt import _engine + +REPO = Path(__file__).resolve().parents[3] +# The Go SDK's EQL fixtures, all sealed under one seed. +FIXTURES = REPO / "packages/eql/tests/encryption/fixtures" + + +def pytest_sessionstart(session): + use = getattr(_engine, "_use_deterministic_keys", None) + if use is None: + pytest.exit( + "the extension was built without the deterministic-kms feature; " + "run `mise run test:deterministic`", + returncode=2, + ) + # Cipher checks that credentials are present before it builds a plan. + # These parse, and the deterministic client never uses them. + os.environ["CS_CLIENT_ACCESS_KEY"] = "CSAKtestKeyId.testKeySecret" + os.environ["CS_WORKSPACE_CRN"] = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY" + frozen = json.loads((FIXTURES / "stored_v4_rows_v1.json").read_text()) + use(frozen["seed"].encode()) diff --git a/languages/python/tests_deterministic/test_concurrency.py b/languages/python/tests_deterministic/test_concurrency.py new file mode 100644 index 0000000000..9baca617f5 --- /dev/null +++ b/languages/python/tests_deterministic/test_concurrency.py @@ -0,0 +1,34 @@ +"""The concurrency test of `tests/test_concurrency_live.py`, with no +credentials: threads that encrypt at once on the one client all succeed, +and every row opens. It runs on every pull request that starts +`tests-python.yml`, a fork's included. + +The model and the threads are `tests/_sync_contract.py`, shared with the +live test. pyproject.toml puts `tests/` on the path. +""" + +from _sync_contract import ( + PER_THREAD, + THREADS, + User, + decrypt_from_threads, + encrypt_from_threads, + user, +) + +from stackencrypt import Cipher + + +def test_threads_encrypt_at_once_and_every_row_opens(): + cipher = Cipher(User) + + rows = encrypt_from_threads(cipher, PER_THREAD) + + assert cipher.decrypt(rows) == [user(n) for n in range(THREADS * PER_THREAD)] + + +def test_threads_decrypt_at_once_and_every_row_opens(): + cipher = Cipher(User) + rows = encrypt_from_threads(cipher, PER_THREAD) + + assert decrypt_from_threads(cipher, rows) == [user(n) for n in range(THREADS * PER_THREAD)] diff --git a/languages/python/tests_deterministic/test_go_rows.py b/languages/python/tests_deterministic/test_go_rows.py new file mode 100644 index 0000000000..d458ef27bc --- /dev/null +++ b/languages/python/tests_deterministic/test_go_rows.py @@ -0,0 +1,300 @@ +"""Python against the EQL rows the Go SDK stored, under the same seed. + +Every fixture here is in `packages/eql/tests/encryption/fixtures/`, written by +the Go SDK's generated code over the deterministic EQL guest. Each model +declares the same table, columns and EQL types as its Go type in +`languages/golang/encrypt/internal/testusers/`. + +- `stored_v4_rows_v1.json` is FROZEN: nothing writes it again. Go + (`TestFrozenV4RowsStillOpen`) and Rust (`tests/v4_types.rs`) check it too. + A build that cannot open it is red. +- `v4_ordered_rows.json`, `v4_smallint_rows.json` and `v4_ledger_rows.json` + are regenerated when the stored format changes on purpose. They hold what + the Go SDK seals today, for the types the frozen file lacks: the order + terms, `Smallint`, dates, timestamps and decimals. + +For each, Python opens the rows Go stored, and seals the same plaintexts to +the same stored values apart from each ciphertext `c`. The equality, order +and match terms are deterministic, so they must be byte-identical. Python's +equality query term for each value must be the term Go stored. +""" + +import dataclasses +import datetime +import decimal +import json +import struct +from pathlib import Path +from typing import Annotated + +import pytest + +import stackencrypt +from stackencrypt import ( + Bigint, + BigintEq, + BigintOrd, + BigintOrdOpe, + Boolean, + Cipher, + Date, + DateEq, + DecryptError, + Double, + DoubleEq, + DoubleOrd, + DoubleOrdOpe, + Integer, + IntegerEq, + IntegerOrd, + IntegerOrdOpe, + Numeric, + NumericEq, + Passthrough, + Real, + RealEq, + RealOrd, + RealOrdOpe, + Smallint, + SmallintEq, + Text, + TextEq, + TextMatch, + TextOrd, + TextOrdOpe, + Timestamp, + TimestampEq, +) + + +@stackencrypt.model("profiles") +@dataclasses.dataclass +class Profile: + id: Annotated[int, Passthrough] + visits: Annotated[int, Integer] + balance: Annotated[int, Bigint] + active: Annotated[bool, Boolean] + ratio: Annotated[float, Real] + score: Annotated[float, Double] + note: Annotated[str, Text] + name: Annotated[str, TextMatch] + age: Annotated[int, IntegerEq] + rank: Annotated[int, BigintEq] + weight: Annotated[float, RealEq] + price: Annotated[float, DoubleEq] + email: Annotated[str, TextEq] + + +@stackencrypt.model("rankings") +@dataclasses.dataclass +class Ranking: + id: Annotated[int, Passthrough] + level: Annotated[int, IntegerOrd] + floor: Annotated[int, IntegerOrdOpe] + points: Annotated[int, BigintOrd] + total: Annotated[int, BigintOrdOpe] + speed: Annotated[float, RealOrd] + pace: Annotated[float, RealOrdOpe] + score: Annotated[float, DoubleOrd] + mean: Annotated[float, DoubleOrdOpe] + title: Annotated[str, TextOrd] + handle: Annotated[str, TextOrdOpe] + + +@stackencrypt.model("profiles") +@dataclasses.dataclass +class Tally: + id: Annotated[int, Passthrough] + small: Annotated[int, Smallint] + tally: Annotated[int, SmallintEq] + wide: Annotated[int, SmallintEq] + + +@stackencrypt.model("profiles") +@dataclasses.dataclass +class Ledger: + id: Annotated[int, Passthrough] + opened: Annotated[datetime.date, Date] + due: Annotated[datetime.date, DateEq] + created: Annotated[datetime.datetime, Timestamp] + posted: Annotated[datetime.datetime, TimestampEq] + total: Annotated[decimal.Decimal, Numeric] + amount: Annotated[decimal.Decimal, NumericEq] + + +# The Go SDK's EQL fixtures. Not imported from conftest.py: `from conftest` +# resolves to whichever conftest.py is first on sys.path. +FIXTURES = Path(__file__).resolve().parents[3] / "packages/eql/tests/encryption/fixtures" + +# (fixture, model, the row ids Python's types can hold). Ledger rows 1 and 2 +# hold years past Python's 1 to 9999 and nanosecond timestamps: the tests +# at the end check each refusal on its own. +CASES = [ + ("stored_v4_rows_v1.json", Profile, [1, 2]), + ("v4_ordered_rows.json", Ranking, [1, 2, 3]), + ("v4_smallint_rows.json", Tally, [1, 2, 3]), + ("v4_ledger_rows.json", Ledger, [3]), +] +IDS = [fixture for fixture, _, _ in CASES] + + +def rows_of(fixture, ids): + rows = json.loads((FIXTURES / fixture).read_text())["rows"] + assert set(ids) <= {row["id"] for row in rows}, (fixture, ids) + return [row for row in rows if row["id"] in ids] + + +def sealed_fields(cls): + return [field for field in dataclasses.fields(cls) if field.name != "id"] + + +def value_of(python_type, plaintext): + """A Go fixture's JSON plaintext as the field's Python value.""" + if python_type is datetime.datetime: + return datetime.datetime.fromisoformat(plaintext.replace("Z", "+00:00")) + if python_type is datetime.date: + return datetime.date.fromisoformat(plaintext) + if python_type is decimal.Decimal: + return decimal.Decimal(plaintext) + # JSON gives 0 for a float field whose value is zero. + return python_type(plaintext) + + +def model_of(cls, row): + values = { + field.name: value_of(field.type.__origin__, row["plaintext"][field.name]) + for field in sealed_fields(cls) + } + return cls(id=row["id"], **values) + + +def stored_row(cls, row): + """The row as psycopg's dict_row gives it: EQL values as JSON text.""" + return { + "id": row["id"], + **{f.name: json.dumps(row["stored"][f.name]) for f in sealed_fields(cls)}, + } + + +def exact(value): + """A form whose == also compares a float's sign, a Decimal's scale, the + type (True is not 1), and an instant whatever its zone.""" + if isinstance(value, float): + return struct.pack("= '3.11'", + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] + +[[package]] +name = "alembic" +version = "1.16.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "mako", version = "1.3.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sqlalchemy", version = "2.0.57", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/ca/4dc52902cf3491892d464f5265a81e9dff094692c8a049a3ed6a05fe7ee8/alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e", size = 1969868, upload-time = "2025-08-27T18:02:05.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3", size = 247355, upload-time = "2025-08-27T18:02:07.37Z" }, +] + +[[package]] +name = "alembic" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "mako", version = "1.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "sqlalchemy", version = "2.0.57", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sqlalchemy", version = "2.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/1c/00909e0fbebad2121d916fdcab044cae988146680e9ee4387ba25fef8747/alembic-1.20.1.tar.gz", hash = "sha256:83490def1f723413db5096591f7609ac52544805be93849170088ed04f5a2e0b", size = 2103576, upload-time = "2026-10-22T03:24:35.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/3c/8740f5b4e1bd57333e4f84265b9b6363da4f7cf70f5622dd1e2fde7a0bd5/alembic-1.20.1-py3-none-any.whl", hash = "sha256:9970712cfb81ea26c8658d378aaa5b8f6ff9146e5bb111d70497c164016cf598", size = 270355, upload-time = "2026-10-22T03:24:37.667Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "greenlet" +version = "3.2.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/f5/3e9eafb4030588337b2a2ae4df46212956854e9069c07b53aa3caabafd47/greenlet-3.2.5.tar.gz", hash = "sha256:c816554eb33e7ecf9ba4defcb1fd8c994e59be6b4110da15480b3e7447ea4286", size = 191501, upload-time = "2026-02-20T20:08:51.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/d6/b3db928fc329b1b19ba32ffe143d2305f3aaafc583f5e1074c74ec445189/greenlet-3.2.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:34cc7cf8ab6f4b85298b01e13e881265ee7b3c1daf6bc10a2944abc15d4f87c3", size = 275803, upload-time = "2026-02-20T20:06:42.541Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/ab0ad4ff3d9e1faa266de4f6c79763b33fccd9265995f2940192494cc0ec/greenlet-3.2.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c11fe0cfb0ce33132f0b5d27eeadd1954976a82e5e9b60909ec2c4b884a55382", size = 633556, upload-time = "2026-02-20T20:30:41.594Z" }, + { url = "https://files.pythonhosted.org/packages/da/dd/7b3ac77099a1671af8077ecedb12c9a1be1310e4c35bb69fd34c18ab6093/greenlet-3.2.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a145f4b1c4ed7a2c94561b7f18b4beec3d3fb6f0580db22f7ed1d544e0620b34", size = 644943, upload-time = "2026-02-20T20:37:23.084Z" }, + { url = "https://files.pythonhosted.org/packages/56/f0/bea7e7909ea9045b0c5055dad1ec9b81c82b761b4567e625f4f8349acfa1/greenlet-3.2.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:edbf4ab9a7057ee430a678fe2ef37ea5d69125d6bdc7feb42ed8d871c737e63b", size = 640849, upload-time = "2026-02-20T20:43:57.305Z" }, + { url = "https://files.pythonhosted.org/packages/0f/36/84630e9ff1dfc8b7690957c0f77834a84eabdbd9c4977c3a2d0cbd5325c2/greenlet-3.2.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc1d01bdd67db3e5711e6246e451d7a0f75fae7bbf40adde129296a7f9aa7cc9", size = 639841, upload-time = "2026-02-20T20:07:17.473Z" }, + { url = "https://files.pythonhosted.org/packages/12/c4/6a2ee6c676dea7a05a3c3c1291fbc8ea44f26456b0accc891471293825af/greenlet-3.2.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd593db7ee1fa8a513a48a404f8cc4126998a48025e3f5cbbc68d51be0a6bf66", size = 588813, upload-time = "2026-02-20T20:07:56.171Z" }, + { url = "https://files.pythonhosted.org/packages/01/c0/75e75c2c993aa850292561ec80f5c263e3924e5843aa95a38716df69304c/greenlet-3.2.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ac8db07bced2c39b987bba13a3195f8157b0cfbce54488f86919321444a1cc3c", size = 1117377, upload-time = "2026-02-20T20:32:48.452Z" }, + { url = "https://files.pythonhosted.org/packages/ee/03/e38ebf9024a0873fe8f60f5b7bc36bfb3be5e13efe4d798240f2d1f0fb73/greenlet-3.2.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4544ab2cfd5912e42458b13516429e029f87d8bbcdc8d5506db772941ae12493", size = 1141246, upload-time = "2026-02-20T20:06:23.576Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7b/c6e1192c795c0c12871e199237909a6bd35757d92c8472c7c019959b8637/greenlet-3.2.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:acabf468466d18017e2ae5fbf1a5a88b86b48983e550e1ae1437b69a83d9f4ac", size = 276916, upload-time = "2026-02-20T20:06:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b6/9887b559f3e1952d23052ec352e9977e808a2246c7cb8282a38337221e88/greenlet-3.2.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:472841de62d60f2cafd60edd4fd4dd7253eb70e6eaf14b8990dcaf177f4af957", size = 636107, upload-time = "2026-02-20T20:30:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/8a/be/e3e48b63bbc27d660fa1d98aecb64906b90a12e686a436169c1330ef34b2/greenlet-3.2.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d951e7d628a6e8b68af469f0fe4f100ef64c4054abeb9cdafbfaa30a920c950", size = 648240, upload-time = "2026-02-20T20:37:24.608Z" }, + { url = "https://files.pythonhosted.org/packages/17/f6/2cbe999683f759f14f598234f04ae8ba6f22953a624b3a7a630003e6bfff/greenlet-3.2.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:87b791dd0e031a574249af717ac36f7031b18c35329561c1e0368201c18caf1f", size = 644170, upload-time = "2026-02-20T20:43:59.002Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ac/e731ed62576e91e533b36d0d97325adc2786674ab9e48ed8a6a24f4ef4e9/greenlet-3.2.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8317d732e2ae0935d9ed2af2ea876fa714cf6f3b887a31ca150b54329b0a6e9", size = 643313, upload-time = "2026-02-20T20:07:19.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/64/99e5cdceb494bd4c1341c45b93f322601d2c8a5e1e4d1c7a2d24c5ed0570/greenlet-3.2.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce8aed6fdd5e07d3cbb988cbdc188266a4eb9e1a52db9ef5c6526e59962d3933", size = 591295, upload-time = "2026-02-20T20:07:57.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e9/968e11f388c2b8792d3b8b40a57984c894a3b4745dae3662dce722653bc5/greenlet-3.2.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:60c06b502d56d5451f60ca665691da29f79ed95e247bcf8ce5024d7bbe64acb9", size = 1120277, upload-time = "2026-02-20T20:32:50.103Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2c/b5f2c4c68d753dce08218dc5a6b21d82238fdfdc44309032f6fe24d285e6/greenlet-3.2.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d2a78e6f1bf3f1672df91e212a2f8314e1e7c922f065d14cbad4bc815059467", size = 1145746, upload-time = "2026-02-20T20:06:26.296Z" }, + { url = "https://files.pythonhosted.org/packages/ad/32/022b21523eee713e7550162d5ca6aed23f913cc2c6232b154b9fd9badc07/greenlet-3.2.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2acb30e77042f747ca81f0a10cc153296567e92e666c5e1b117f4595afd43352", size = 278412, upload-time = "2026-02-20T20:03:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/90/c5/8a3b0ed3cc34d8b988a44349437dfa0941f9c23ac108175f7b4ccea97111/greenlet-3.2.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:393c03c26c865f17f31d8db2f09603fadbe0581ad85a5d5908b131549fc38217", size = 644616, upload-time = "2026-02-20T20:30:44.823Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/2627bea183554695016af6cae93d7474fa90f61e5a6601a84ae7841cb720/greenlet-3.2.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:04e6a202cde56043fd355fefd1552c4caa5c087528121871d950eb4f1b51fa99", size = 658813, upload-time = "2026-02-20T20:37:26.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/c6/a80fc96f7cca7962dd972875d12c52dfabc94cb02bfeb19f3e7e169fca44/greenlet-3.2.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d5583b2ffa677578a384337ee13125bdf9a427485d689014b39d638a4f3d8dbe", size = 653512, upload-time = "2026-02-20T20:44:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/2f/1b/75a5aeff487a26ba427a3837da6372f1fe6f2a9c6b2898e28ac99d491c11/greenlet-3.2.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:45fcea7b697b91290b36eafc12fff479aca6ba6500d98ef6f34d5634c7119cbe", size = 655426, upload-time = "2026-02-20T20:07:20.124Z" }, + { url = "https://files.pythonhosted.org/packages/53/91/9b5dfb4f3c88f8247c7a8f4c3759f0740bfa6bb0c59a9f6bf938e913df56/greenlet-3.2.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96e2bb8a56b7e1aed1dbfbbe0050cb2ecca99c7c91892fd1771e3afab63b3e3", size = 611138, upload-time = "2026-02-20T20:07:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8d/d0b086410512d9859c84e9242a9b341de9f5566011ddf3a3f6886b842b61/greenlet-3.2.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d7456e67b0be653dfe643bb37d9566cd30939c80f858e2ce6d2d54951f75b14a", size = 1126896, upload-time = "2026-02-20T20:32:52.198Z" }, + { url = "https://files.pythonhosted.org/packages/ef/37/59fe12fe456e84ced6ba71781e28cde52a3124d1dd2077bc1727021f49fd/greenlet-3.2.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5ceb29d1f74c7280befbbfa27b9bf91ba4a07a1a00b2179a5d953fc219b16c42", size = 1154779, upload-time = "2026-02-20T20:06:27.583Z" }, + { url = "https://files.pythonhosted.org/packages/dd/95/d5d332fb73affaf7a1fbe80e49c2c7eae4f17c645af24a3b3fa25736d6f0/greenlet-3.2.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f2cc88b50b9006b324c1b9f5f3552f9d4564c78af57cdfb4c7baf4f0aa089146", size = 277166, upload-time = "2026-02-20T20:03:57.077Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/89458e20db5a4f1c64f9a0191561227e76d809941ca2d7529006d17d3450/greenlet-3.2.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e66872daffa360b2537170b73ad530f14fa31785b1bc78080125d92edf0a6def", size = 644674, upload-time = "2026-02-20T20:30:46.118Z" }, + { url = "https://files.pythonhosted.org/packages/90/f8/9962175d2f2eaa629a7fd7545abacc8c4deda3baa4e52c1526d2eb5f5546/greenlet-3.2.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c5445ddb7b586d870dad32ca9fc47c287d6022a528d194efdb8912093c5303ad", size = 658834, upload-time = "2026-02-20T20:37:27.466Z" }, + { url = "https://files.pythonhosted.org/packages/81/71/52c21a7106ce5218aa6fa59ec32825b2655f875a09b69f68bd3e5d01feb3/greenlet-3.2.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd904626b8779810062cb455514594776e3cba3b8c0ba4939894df9f7b384971", size = 653091, upload-time = "2026-02-20T20:44:01.927Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d7/826d0e080f0a7ad5ec47c8d143bbd3ca0887657bb806595fe2434d12938a/greenlet-3.2.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:752c896a8c976548faafe8a306d446c6a4c68d4fd24699b84d4393bd9ac69a8e", size = 655760, upload-time = "2026-02-20T20:07:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/41/cc/33bd4c2f816be8c8e16f71740c4130adf3a66a3dd2ba29de72b9d8dd1096/greenlet-3.2.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499b809e7738c8af0ff9ac9d5dd821cb93f4293065a9237543217f0b252f950a", size = 614132, upload-time = "2026-02-20T20:08:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/48/79/f3891dcfc59097474a53cc3c624f2f2465e431ab493bda043b8c873fb20a/greenlet-3.2.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2c7429f6e9cea7cbf2637d86d3db12806ba970f7f972fcab39d6b54b4457cbaf", size = 1125286, upload-time = "2026-02-20T20:32:54.032Z" }, + { url = "https://files.pythonhosted.org/packages/ca/47/212b47e6d2d7a04c4083db1af2fdd291bc8fe99b7e3571bfa560b65fc361/greenlet-3.2.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a5e4b25e855800fba17713020c5c33e0a4b7a1829027719344f0c7c8870092a2", size = 1152825, upload-time = "2026-02-20T20:06:29Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9d/4e9b941be05f8da7ba804c6413761d2c11cca05994cbf0a015bd729419f0/greenlet-3.2.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7123b29e6bad2f3f89681be4ef316480fca798ebe8d22fbaced9cc3775007a4f", size = 277627, upload-time = "2026-02-20T20:06:04.798Z" }, + { url = "https://files.pythonhosted.org/packages/23/cb/a73625c9a35138330014ecf3740c0d62e0c2b5e7279bb7f2586b1b199fac/greenlet-3.2.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6e8fe0c72603201a86b2e038daf9b6c8570715f8779566419cff543b6ace88de", size = 690001, upload-time = "2026-02-20T20:30:47.754Z" }, + { url = "https://files.pythonhosted.org/packages/83/49/6d1531109507bce7dfb23acf57a87013627ed3ac058851176e443a6a9134/greenlet-3.2.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:050703a60603db0e817364d69e048c70af299040c13a7e67792b9e62d4571196", size = 702953, upload-time = "2026-02-20T20:37:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/90/ac/6d8fff3b273fc60ad4b46f8411fe91c1e4cca064dfff68d096bc982fa6d0/greenlet-3.2.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:04633da773ae432649a3f092a8e4add390732cc9e1ab52c8ff2c91b8dc86f202", size = 698353, upload-time = "2026-02-20T20:44:03.547Z" }, + { url = "https://files.pythonhosted.org/packages/f7/38/f958ee90fab93529b30cc1e4a59b27c1112b640570043a84af84da3b3b98/greenlet-3.2.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6712bfd520530eb67331813f7112d3ee18e206f48b3d026d8a96cd2d2ad20251", size = 698995, upload-time = "2026-02-20T20:07:22.663Z" }, + { url = "https://files.pythonhosted.org/packages/51/c1/a603906e79716d61f08afedaf8aed62017661457aef233d62d6e57ecd511/greenlet-3.2.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc06a78fa3ffbe2a75f1ebc7e040eacf6fa1050a9432953ab111fbbbf0d03c1", size = 661175, upload-time = "2026-02-20T20:08:01.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8f/f880ff4587d236b4d06893fb34da6b299aa0d00f6c8259673f80e1b6d63c/greenlet-3.2.5-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:dbe0e81e24982bb45907ca20152b31c2e3300ca352fdc4acbd4956e4a2cbc195", size = 274946, upload-time = "2026-02-20T20:05:21.979Z" }, + { url = "https://files.pythonhosted.org/packages/3c/50/f6c78b8420187fdfe97fcf2e6d1dd243a7742d272c32fd4d4b1095474b37/greenlet-3.2.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:15871afc0d78ec87d15d8412b337f287fc69f8f669346e391585824970931c48", size = 631781, upload-time = "2026-02-20T20:30:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/26/d6/3277f92e1961e6e9f41d9f173ea74b5c1f7065072637669f761626f26cc0/greenlet-3.2.5-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5bf0d7d62e356ef2e87e55e46a4e930ac165f9372760fb983b5631bb479e9d3a", size = 643740, upload-time = "2026-02-20T20:37:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/c37b87659378759f158dbe03eaeb7ed002a8968f1c649b2972f5323f99b2/greenlet-3.2.5-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:e3f03ddd7142c758ab41c18089a1407b9959bd276b4e6dfbd8fd06403832c87a", size = 639098, upload-time = "2026-02-20T20:44:07.287Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6a/4f79d2e7b5ef3723fc5ffea0d6cb22627e5f95e0f19c973fa12bf1cf7891/greenlet-3.2.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6dff6433742073e5b6ad40953a78a0e8cddcb3f6869e5ea635d29a810ca5e7d0", size = 638382, upload-time = "2026-02-20T20:07:23.883Z" }, + { url = "https://files.pythonhosted.org/packages/4d/59/7aadf33f23c65dbf4db27e7f5b60c414797a61e954352ae4a86c5c8b0553/greenlet-3.2.5-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdd67619cefe1cc9fcab57c8853d2bb36eca9f166c0058cc0d428d471f7c785c", size = 587516, upload-time = "2026-02-20T20:08:02.841Z" }, + { url = "https://files.pythonhosted.org/packages/1d/46/b3422959f830de28a4eea447414e6bd7b980d755892f66ab52ad805da1c4/greenlet-3.2.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3828b309dfb1f117fe54867512a8265d8d4f00f8de6908eef9b885f4d8789062", size = 1115818, upload-time = "2026-02-20T20:32:55.786Z" }, + { url = "https://files.pythonhosted.org/packages/54/4a/3d1c9728f093415637cf3696909fa10852632e33e68238fb8ca60eb90de1/greenlet-3.2.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:67725ae9fea62c95cf1aa230f1b8d4dc38f7cd14f6103d1df8a5a95657eb8e54", size = 1140219, upload-time = "2026-02-20T20:06:30.334Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/4f/4af258e1ce388eb64be82d6466b7d0b3bc3eede154e14c3360a76a759b71/greenlet-3.5.6-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:95e7c44d072db623a1aab04ce488cf9533294a77ed9d072cd503a3596f4106ac", size = 292967, upload-time = "2026-09-14T14:26:34.475Z" }, + { url = "https://files.pythonhosted.org/packages/1d/05/dc0d54e90af2f192724936799cee990687792ff54a4638d3ae0e0a0145c0/greenlet-3.5.6-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7d501d5eb5d4f67207df364752ad697465b834268744be7581c18d81d35d41d", size = 609284, upload-time = "2026-09-14T15:11:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/c1/51/826b4fe7bc39c3f910c95e889a981c827f72a611fc035f31816a256cf043/greenlet-3.5.6-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a364c1ea75dc51b83a17f52fe0c79cf8bc4ddf740403bebd4581c7666eea017d", size = 622648, upload-time = "2026-09-14T15:20:39.198Z" }, + { url = "https://files.pythonhosted.org/packages/55/3b/f2d36fc38934588dff6d6ec9288934c9611abac7a79c866c2002fc707698/greenlet-3.5.6-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5599b380c1f28efeb724e81569eac80cd92f99a85bd9775456caaf3225d40b11", size = 629557, upload-time = "2026-09-14T15:25:03.036Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/092682ae7ca1aadd44aa36b0bc35c48c9ed55f9ffebb5794980b1e2ae74b/greenlet-3.5.6-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eed88b64a5e5da72d6a71cdc5aaeefaa5ced9b748f8d19f89800b339961dad39", size = 622813, upload-time = "2026-09-14T14:35:54.845Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/4167f0e44e795840cbc219b2e5d8f576492192cd9e3e17e827db20287daa/greenlet-3.5.6-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:5bbda3c70dd35d60671bc33b01916802707a052130d9e50cdb871d34594d35cb", size = 425482, upload-time = "2026-09-14T15:28:34.144Z" }, + { url = "https://files.pythonhosted.org/packages/07/59/9c6b723a559b53bee0980a5dc57577d434891dd454f0fe54ed6e22d4e4ce/greenlet-3.5.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:874cea8bb1ec1ddccbacbd027856f6bf496f6bc18aba97a918c20e067edab236", size = 1585817, upload-time = "2026-09-14T15:10:03.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/d4/2bceaa305ff0ecf99511480da8f0a866eb2469a68da8715008a0d3e8f65f/greenlet-3.5.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:128813fc29f2336a21b4d06eedd5e16bcc7ea46f59e9ff1cb30ea70e48195d88", size = 1650177, upload-time = "2026-09-14T14:35:46.736Z" }, + { url = "https://files.pythonhosted.org/packages/7a/33/c57855a6abada0c7bfb9c6c0f33df1fbed8e5224708478dbcdff6c5db490/greenlet-3.5.6-cp310-cp310-win_amd64.whl", hash = "sha256:dad3d233d441a022c1f7155f0fb9d5aff7b97c1ea8c7dfa02cce586b16ab2d0b", size = 322896, upload-time = "2026-09-14T14:24:52.308Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d7/41511ee2696f14be4200b524d9553dc4295e2bdeb20aa8962c3cb25e71c6/greenlet-3.5.6-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a6a4b98a9132e0f45c9fc245a63894cfd8c45fb7a0d6bffc5eab3ec327cf7324", size = 294075, upload-time = "2026-09-14T14:25:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7b/b509624970909294cd064ff7346148ca9941c21bec9026d7873dd254e9fa/greenlet-3.5.6-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45bfd2b51e38aaa5f9849f114d9c7c1d75f69187c849b3549cd64c465283abfa", size = 613429, upload-time = "2026-09-14T15:12:00.454Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5c/d2eb503067f9ba20875ef8c87681f29a64f53bbbbe4059a5d7c53179d442/greenlet-3.5.6-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c6dede9133e1da41d561bc3fb14e92b47e2ce39ae60edefaad145658ea7c5e2", size = 625405, upload-time = "2026-09-14T15:20:41.053Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/9b071d11c8bb9f5f38cccacc38fcc234d91997a4c395cc2bf43ecae89642/greenlet-3.5.6-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4fb8e59f68845d56c23c031dcd79c329f345e4a9d2ffac91c3d1ab366bdc457b", size = 633270, upload-time = "2026-09-14T15:25:04.864Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d3/63d4477ce31dff2fd802a9a20240f6606aac85977e0fb18443aae33de3f6/greenlet-3.5.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c20ea32a73d17b9b60e3371240e17b0068120c98a5ec01a224a7dd8c89733ba", size = 624428, upload-time = "2026-09-14T14:35:56.895Z" }, + { url = "https://files.pythonhosted.org/packages/88/17/ac11883ecc9da19c681c8b763ee39e6f7dca2aa81874eb11a075d3cbeb00/greenlet-3.5.6-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d701eab36200c36224833d07dbdb709adb7fd4253429548ddb5e547b8ed40586", size = 428068, upload-time = "2026-09-14T15:28:35.872Z" }, + { url = "https://files.pythonhosted.org/packages/ad/aa/9cde4e00688eaa2a03b91d12e4681439a87e6aad860399e0847af6a014ca/greenlet-3.5.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a0b2791239c99992a86c1b635b787fe2a877d9eaaa26f8891ce943832b585ae", size = 1588385, upload-time = "2026-09-14T15:10:05.386Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/24632b5ec186b64e21e07a8f53ce5e15a7e9cb33eddee99a5fe16379afa5/greenlet-3.5.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:188bf333769b7145e2b0b4a7f09615ec550ed44d3a2a8395fb7b36f0e9901e13", size = 1653119, upload-time = "2026-09-14T14:35:48.275Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6c/019d2ef898f4b9ac845167f1c6f73229e9a4e2439362a5e2ce50205a19b0/greenlet-3.5.6-cp311-cp311-win_amd64.whl", hash = "sha256:a6b4ff33f7e011bbaa148238d131c4fd4f8afbab3c104ddfbdb2b12b74ff7016", size = 323317, upload-time = "2026-09-14T14:22:38.836Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7a/439df999455e3bdf02b1c68f3848d4020385ef0a01f89f706b07bf148a65/greenlet-3.5.6-cp311-cp311-win_arm64.whl", hash = "sha256:59deccd347735a7774223b05a93773fddbb298aba3cea21be4337fb4752dbe32", size = 307739, upload-time = "2026-09-14T14:23:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" }, + { url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f2/844dc823ff2752ad049caa6b59d57e4572f9c445934b02d3518f4c67197c/greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188", size = 629529, upload-time = "2026-09-14T15:25:06.354Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/c0/42/b1f8dbc89a53b9e77859fc1ad1627d106fc361daa3ea4bdf43a91ebb4338/greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc", size = 432385, upload-time = "2026-09-14T15:28:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" }, + { url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" }, + { url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632, upload-time = "2026-09-14T15:25:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933, upload-time = "2026-09-14T15:28:38.858Z" }, + { url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" }, + { url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" }, + { url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" }, + { url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" }, + { url = "https://files.pythonhosted.org/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" }, + { url = "https://files.pythonhosted.org/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" }, + { url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" }, + { url = "https://files.pythonhosted.org/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" }, + { url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" }, + { url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" }, + { url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" }, + { url = "https://files.pythonhosted.org/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" }, + { url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" }, + { url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" }, + { url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "markupsafe", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "mako" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "markupsafe", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/09/e07c4b5579a79f4b16f8d4f29f6c54514ac787c4ad506b8c4f28a0e6b0bf/mako-1.4.3.tar.gz", hash = "sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a", size = 412799, upload-time = "2026-09-22T20:54:31.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/a0/053d6af3e8f871e0073b4a36732d9e65be77a72e5434c31b94f6af78a6bb/mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f", size = 80164, upload-time = "2026-09-22T20:54:33.128Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/aa/9a65962e364bf19745f6bec7bde398fb1f5ca53ad2e734258e6198cc32ba/markupsafe-3.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd8ea6ebee7aedbf7c749fa80521d9ccf1ba473e0d1e14805caafbaad281c889", size = 11521, upload-time = "2026-10-02T23:04:20.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/36/927999a34b7d1def6957de89153d327fedc4030061940b5a468584e6c5a8/markupsafe-3.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dff05cb7016dff1e9fd68f4122c127b65dfc59de5306cfb7ad92f956f230bee2", size = 12066, upload-time = "2026-10-02T23:04:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/17/54/69e7b0db9bd687bfd83451eed5666384d67cfed3be062bfc59a06a15474e/markupsafe-3.0.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf63c214fe879a65e69a386f915e36104fc84254ab141240f8854602d8e0be2a", size = 22081, upload-time = "2026-10-02T23:04:22.221Z" }, + { url = "https://files.pythonhosted.org/packages/bd/14/f6f5c97903f7d2db76bbfaced31509a47d360a103b3aaf4849f6536591ec/markupsafe-3.0.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2a6ef68ae94aed8721934072b27a3b654ea2100b97e4ab864cf1489c90926fbc", size = 26079, upload-time = "2026-10-02T23:04:23.346Z" }, + { url = "https://files.pythonhosted.org/packages/d0/04/c3cc9b75f94f8b54d7e503c44cebd4b4a115ec1d6f1b996b999807daba99/markupsafe-3.0.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd9f8797427910198f95bced71ddfed61130d7e349213bfb8466c9c99e2c46a8", size = 22611, upload-time = "2026-10-02T23:04:24.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/26/c4708ed3b0f08e8e6d7cbce3314ac130cb5352d1f62951b6fef7878f2b1e/markupsafe-3.0.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1aca03ede943eb80ab3d63bb082c84b7aab85ea83bd0fd0c200260945fb49d9", size = 20712, upload-time = "2026-10-02T23:04:25.386Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/f3894d6aae3d7a52c9363317f4baf2fcadc052163d6dbc871266e32639ed/markupsafe-3.0.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0764a13d34cae40db7bbf3a09b7e9b491bf4603e20b263a7a9d6b8e324975d0a", size = 20742, upload-time = "2026-10-02T23:04:26.403Z" }, + { url = "https://files.pythonhosted.org/packages/59/7c/8e248ddbfe286ab6bddb462bdac0851cbb1510d2cbbb815a415fcb0511af/markupsafe-3.0.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9388003072b95f2f1e3fd908604194d653ba21330d811961a78b7da1a77e9e36", size = 21560, upload-time = "2026-10-02T23:04:27.522Z" }, + { url = "https://files.pythonhosted.org/packages/df/26/2353fef7d4bcf2b18e16bad81979fcecff2915156ca6882447917205b8e2/markupsafe-3.0.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8698d70a8081ee8c090dbb394768b5789a1da8b131b5499f89d071dd3cfaf6be", size = 20973, upload-time = "2026-10-02T23:04:28.573Z" }, + { url = "https://files.pythonhosted.org/packages/67/6f/a9561d98d9a6ee3494b0b970a1c766e58bab128cc84841d56ec009456dbd/markupsafe-3.0.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf053da3c97a4bc5ecfbb218cdd2983febd91c617be8367d139882aa11e490aa", size = 22036, upload-time = "2026-10-02T23:04:29.568Z" }, + { url = "https://files.pythonhosted.org/packages/55/83/6217df9192eca95af3ff0cad955c9854a93fa43b288cf7411ae24713eb52/markupsafe-3.0.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:9438a2648b2195980cb2dd8e53ed7b8df91319e2d0b70ae61a9e1d1bc8d3bec9", size = 20314, upload-time = "2026-10-02T23:04:30.881Z" }, + { url = "https://files.pythonhosted.org/packages/35/7c/9cd8081dae70e17fdab3558121fa618d459c7950c8abd8fc10fc024486d7/markupsafe-3.0.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:88d59b473bfb03259722600839af9bbd7fa13a2eb514beefeedb95997882f69a", size = 20780, upload-time = "2026-10-02T23:04:31.975Z" }, + { url = "https://files.pythonhosted.org/packages/44/37/c5f2f45f4d8f0c24e4af7b9dc711640c888044cbf5f7e2ba2c0bb74cbb55/markupsafe-3.0.4-cp310-cp310-win32.whl", hash = "sha256:4a540e2d3192792fc84eced57bef37851ccb2b41f73291bb17408eea77bcd278", size = 14096, upload-time = "2026-10-02T23:04:33.129Z" }, + { url = "https://files.pythonhosted.org/packages/50/50/394a1c61c9b9972cb4f76875c332af7109bd39bdad09d3a0ec5327a339dc/markupsafe-3.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:5c22873ad1f0532ba40fa1727f3c0fc1bbbaab6d373d4cbe3f0dc74b2e2521c7", size = 14329, upload-time = "2026-10-02T23:04:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9a/86d03d2f32ba41a57baa124a7bcb76d0a6d39b1622e9bd4d550074e8b61a/markupsafe-3.0.4-cp310-cp310-win_arm64.whl", hash = "sha256:3d23795802fc8bd72534836d64489bbf0f67c088959091bdb22e10735a5107bf", size = 14196, upload-time = "2026-10-02T23:04:35.201Z" }, + { url = "https://files.pythonhosted.org/packages/32/55/18dbb4778b30ada5ce071608503cc3edc9e14e13d868c17a6d178fc30f7a/markupsafe-3.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346", size = 11520, upload-time = "2026-10-02T23:04:36.299Z" }, + { url = "https://files.pythonhosted.org/packages/6c/14/0b05f79b4733e264a18d08fe08fa1df7347630ff32a6cb82180d9dccec55/markupsafe-3.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91", size = 12063, upload-time = "2026-10-02T23:04:37.291Z" }, + { url = "https://files.pythonhosted.org/packages/ca/3a/63ba10b6c1463216b3e4df669a9f0e5a3b0c3071557d2e8229e3968c79fb/markupsafe-3.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef", size = 24321, upload-time = "2026-10-02T23:04:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/5f015261b76ad633d187ef6f388b413aedd64a8773c4df59e530a0be5525/markupsafe-3.0.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169", size = 28845, upload-time = "2026-10-02T23:04:39.295Z" }, + { url = "https://files.pythonhosted.org/packages/33/cf/26e594b26be40c2f1fec63ccf8a8b99d0335a5b2cbe84835c7d82a994375/markupsafe-3.0.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb", size = 24701, upload-time = "2026-10-02T23:04:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/81/a5/a513b76c139a3915b43404324e55c0b7979ae4f0d39eb6f075b0282e90a8/markupsafe-3.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808", size = 22961, upload-time = "2026-10-02T23:04:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/4c66192c100b4542bcbe392ae06696b670f66927be3ac38a213234778ff9/markupsafe-3.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692", size = 21920, upload-time = "2026-10-02T23:04:42.429Z" }, + { url = "https://files.pythonhosted.org/packages/cb/17/ac3662678bfbad649893117ada2ba44dc30bf56884e84f13154a792b10f1/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d", size = 23726, upload-time = "2026-10-02T23:04:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/f7/af/fe47cee339180a69ebca3c57fb3483d0f5cbd1e8337d1871fb1d9c1aebee/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21", size = 22539, upload-time = "2026-10-02T23:04:44.618Z" }, + { url = "https://files.pythonhosted.org/packages/12/32/d55440ba140442800e02d799c9cb5ab597bf6ebdb1177b5ea39a11f797bd/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707", size = 23963, upload-time = "2026-10-02T23:04:45.656Z" }, + { url = "https://files.pythonhosted.org/packages/50/9d/9c86042cb364c2ad4c971e6d1247929effd25f714cd7ee11b05e6316445b/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e", size = 21506, upload-time = "2026-10-02T23:04:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/75/ef/5b824f03ba40c3b3652b6272d083d2fc4fcdd440de519a3a39ba2c3e7262/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7", size = 22955, upload-time = "2026-10-02T23:04:47.698Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e8/44cfcb5ea40e5e43cec7793ef90704ed0c475280839076c4345757eb8e59/markupsafe-3.0.4-cp311-cp311-win32.whl", hash = "sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5", size = 14094, upload-time = "2026-10-02T23:04:48.788Z" }, + { url = "https://files.pythonhosted.org/packages/91/89/f2b509f7bf79352e40117824c1070dbeafd4df67031d3fa98165a3134228/markupsafe-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3", size = 14328, upload-time = "2026-10-02T23:04:49.801Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5a/ccf22672a0f64dc682306e288f0dabcb06c7a201f3bb6e6cbf86d9e8ad03/markupsafe-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e", size = 14188, upload-time = "2026-10-02T23:04:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, + { url = "https://files.pythonhosted.org/packages/2d/2e/a974ab7076b0272a20f3b01af857c5c78a5c7304125ac39d9eacad3fb1d8/markupsafe-3.0.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f291bcf42ae98eb5107edb162c3c998b4a89648fd8e99ed4cbd12705292788cd", size = 11515, upload-time = "2026-10-02T23:06:57.721Z" }, + { url = "https://files.pythonhosted.org/packages/91/e5/1f4a9b31bc89d08150aa1211d8187d69e240decc09aa92575087f12c5ded/markupsafe-3.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ac0c7c9f1609b0c4c114feb1d7a3409564c7fb77e360bed9e97e5d25dfeaf868", size = 12060, upload-time = "2026-10-02T23:06:59.149Z" }, + { url = "https://files.pythonhosted.org/packages/98/fe/dbcabd91abdb382f9fbd2ae1b02354c727d90bbdcce79db98cfb687d70b0/markupsafe-3.0.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6768d67d1bce64270e0fdc2e69309d68b9b18ae56ddf6c711d168e9d051c2cac", size = 21956, upload-time = "2026-10-02T23:07:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b9/b39a16a1fc3111cce4a6d74ae19b696757503300f410abfc75f5fc9043be/markupsafe-3.0.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:14bd2d845d62ab678eaf81da89d7b621b51756c72346745c1a594c09d49207a2", size = 25915, upload-time = "2026-10-02T23:07:02.007Z" }, + { url = "https://files.pythonhosted.org/packages/ee/35/53eef45f90496d9b5ff3c2cc2b1f66a92f7cc1361a80ce6723ace426b375/markupsafe-3.0.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:007e1ffd9bf65bb6ee96df7b258fc632a4868dd5566037986c64781f35a36e98", size = 22464, upload-time = "2026-10-02T23:07:03.394Z" }, + { url = "https://files.pythonhosted.org/packages/55/a2/72769102a02abdb32faf9f10abbd4d9ba80b50949b2fe668c27e2432fa82/markupsafe-3.0.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e8b3d0b18fd623afa12ecb2ce8d8becef69f9b5440c6330c7972200e0bb84b0", size = 20583, upload-time = "2026-10-02T23:07:05.055Z" }, + { url = "https://files.pythonhosted.org/packages/54/a0/6b37c580383117881616e5fa13ccfcd27b711c99d17c59247830772677c6/markupsafe-3.0.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57f9947a7e57a081c1e3e0a2dd0d2dcf290a4531450e6f611e30084c222a7295", size = 20618, upload-time = "2026-10-02T23:07:06.521Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ff/e878b3a76c9f4e6d050c6709cc36d4e5a24a817c7cb2dd2a4230348677b4/markupsafe-3.0.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b61687d0828e72bf5cda24a2690188f37170bd31c9359ac97e4e66569f120a16", size = 21420, upload-time = "2026-10-02T23:07:08.332Z" }, + { url = "https://files.pythonhosted.org/packages/77/0e/1b9d5c3197aa7f2a993d476f4ea3fd677656c1be0f8f87b5cdf3e18c88cc/markupsafe-3.0.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:0cee7cb0f9a1b6892ea482237d9403b3d1b4603aee057d0ff01f0fac2d019a97", size = 20826, upload-time = "2026-10-02T23:07:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ea/906a72b984b8b928d596ee7f57a2051dd2cec94ca9be383fb52c9ad19418/markupsafe-3.0.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:94e4c421742086aeee4c32a506eec8859d7634aad943f7e6aacf70f813478768", size = 21876, upload-time = "2026-10-02T23:07:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3b/8099c4f4e2793ca067bdc5670e2898fedeb3f49e6d17e7810fa094aad54e/markupsafe-3.0.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9240187afb63d2f9ddc3e032c670356fe941f6e20662ea168a5dc3f1f317e1b3", size = 20167, upload-time = "2026-10-02T23:07:13.338Z" }, + { url = "https://files.pythonhosted.org/packages/28/4b/085aa6f3d1572d8284a6670f66edda0cee772860f3bdc1431cceff5163e7/markupsafe-3.0.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e841068dc0be4cb6dfb5c890eb88cbdcff2f4a332393c7ec94e8e618bd32c1a8", size = 20621, upload-time = "2026-10-02T23:07:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/e2/24/34eba90224606cf2b5327914e80c1cda8718b9dc88f7331d1b32331de71a/markupsafe-3.0.4-cp39-cp39-win32.whl", hash = "sha256:f61efe1d2fe0de16158a5fe1d1cf3c14bdb6aecd54d8938fd26512c525c1f624", size = 14091, upload-time = "2026-10-02T23:07:16.399Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/7ae8c8656b913431eabb4e8b30aea311aa7a68217b56609ec20306732b60/markupsafe-3.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:2b2b1e18af909b448bb3cf9e3433366f7a8726271fc214e8b10e0f62a78c724b", size = 14324, upload-time = "2026-10-02T23:07:18.982Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7d/87ff065a4d14d8de1f462f851d9cdf903b6e1fc067d0ef3674414da444c0/markupsafe-3.0.4-cp39-cp39-win_arm64.whl", hash = "sha256:6669c1bf34080161ce49c589cc512ef24d4c704ac9d2b2d3667f519c60418378", size = 14190, upload-time = "2026-10-02T23:07:20.567Z" }, +] + +[[package]] +name = "maturin" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/71/eb078174335ff30bde1be2ad54fb6256db4b160eb3efa81163fa30f8c814/maturin-1.16.0.tar.gz", hash = "sha256:13e69ca12542999d8c5467131de72a49bb76e4c284b9a45c5783df7c0fcb3a8a", size = 396641, upload-time = "2026-10-29T14:09:43.096Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/f8/235d4a8b3d61af3c7744582ae9e6ccb8f096e9f68f06fe62d7d03e5d7b2d/maturin-1.16.0-py3-none-linux_armv6l.whl", hash = "sha256:deacbb204a20326862e194c7df4958e0514a0411c9a3ccf228a00067c11b5c7e", size = 10425070, upload-time = "2026-10-29T14:09:06.221Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d5/f49a90d8c489dc5f134654ec91d12a78a51599fdc8c2b5a1bc2391bca0ac/maturin-1.16.0-py3-none-macosx_10_12_universal2.macosx_10_12_x86_64.macosx_11_0_arm64.whl", hash = "sha256:131fcc17ddb3ccc48aa183d2172ebf994c322b858e1b9694fb8744d783df66f6", size = 19839189, upload-time = "2026-10-29T14:09:09.441Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8e/d65633d7c25484cb82735a51e15bc969f9874a13557e2b352d27df574c20/maturin-1.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2216d731c9324869d3bff72c0c73009bb6e640b3fa7d340174ae70dc874d1d9f", size = 10271659, upload-time = "2026-10-29T14:09:12.596Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dc/e77f490557cb5e045a75c3d18a7965425ac576235c970dfff4d903eb2c74/maturin-1.16.0-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:837db2f4d93d4899c3ac393fb3cc9eebf35b6002336e5d574f4a4fb6c49fe283", size = 10763361, upload-time = "2026-10-29T14:09:16.11Z" }, + { url = "https://files.pythonhosted.org/packages/fa/eb/535564a4211105fd64611fa4f9437c8925eded26a97d58de84565f5aebfa/maturin-1.16.0-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:399fadcd4af39532a173e010240c34930eacc1bb1746bb179863481c68bcf1ab", size = 10442856, upload-time = "2026-10-29T14:09:18.64Z" }, + { url = "https://files.pythonhosted.org/packages/45/5b/570e13ce81f103be6609c067a06c5bac08da5af0cf7ea700c9ae43419be9/maturin-1.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:e285ec9eb10243d45d10417178cb2eb76e0cbb184d0aa4378b503489fa12ee78", size = 10298796, upload-time = "2026-10-29T14:09:21.385Z" }, + { url = "https://files.pythonhosted.org/packages/88/a6/26b40c31c354359fe4e49398113d018694c7035968fe1b569bb13c3905af/maturin-1.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:609228d639c5b888dc41364cbd318547a59f027481f53cba85838f46da4b3f02", size = 10268023, upload-time = "2026-10-29T14:09:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/2b/19/faca1784d917c2eecd498648dff6a0179b299c8142cc448ea5b1183545ec/maturin-1.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:7001fd3ac591820d01f2fa041552c367ec587c98649d9f10a817cc7f28e59062", size = 13739038, upload-time = "2026-10-29T14:09:26.662Z" }, + { url = "https://files.pythonhosted.org/packages/36/c1/d67e77aa07100e3b2d4c0d9ef6922646d9c4bab25cc04f91023aefc72418/maturin-1.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b0573a146803f24566f5b301a4572a8e49bcab4917175407c730da13b46e134", size = 11100193, upload-time = "2026-10-29T14:09:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/76/44/ccef52f02ab0e1f7258bc8fe0140b4991c6287458ef883af1027f946a253/maturin-1.16.0-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:c16d3589cbddda9420237fdb1394b1a9b36483c0bce83b23de6118c908a8e940", size = 10663849, upload-time = "2026-10-29T14:09:32.183Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9b/3104f62dbb428f9e26e35cba2c80cfc42ba9bb1dc4968882c19d1bfe594b/maturin-1.16.0-py3-none-win32.whl", hash = "sha256:9e221c047931caa79283cc0c2278904ae1b25880dde3574b0447657d72907689", size = 9124894, upload-time = "2026-10-29T14:09:34.857Z" }, + { url = "https://files.pythonhosted.org/packages/63/b8/c25ce706c4797ee1663bcf1f386ffe91d8a3b3589700a135a204b49bc4b0/maturin-1.16.0-py3-none-win_amd64.whl", hash = "sha256:9d43a2adf51a2794e907a8f7dbad06eb114d5edb0909c73c1fa9040f15e584e9", size = 10575645, upload-time = "2026-10-29T14:09:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/eb/74/f69aba8b22539e5ed58323d4a6ee974afd9edda238489c73926cc0fc9d51/maturin-1.16.0-py3-none-win_arm64.whl", hash = "sha256:95f34ce53249fb7095ff4f2aff64d2562c511d6edf3e216c15dbffcd82b72b4f", size = 9886475, upload-time = "2026-10-29T14:09:40.603Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "psycopg" +version = "3.2.13" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "tzdata", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/05/d4a05988f15fcf90e0088c735b1f2fc04a30b7fc65461d6ec278f5f2f17a/psycopg-3.2.13.tar.gz", hash = "sha256:309adaeda61d44556046ec9a83a93f42bbe5310120b1995f3af49ab6d9f13c1d", size = 160626, upload-time = "2025-11-21T22:34:32.328Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/14/f2724bd1986158a348316e86fdd0837a838b14a711df3f00e47fba597447/psycopg-3.2.13-py3-none-any.whl", hash = "sha256:a481374514f2da627157f767a9336705ebefe93ea7a0522a6cbacba165da179a", size = 206797, upload-time = "2025-11-21T22:29:39.733Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", version = "3.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg" +version = "3.3.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "tzdata", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/c8/3bd7e09770a5829eb26667df5e49f084d78bd106d5949ade9447288a8e18/psycopg-3.3.8.tar.gz", hash = "sha256:86c135a9a6f5e98404975c5787e620bf436a7807e088baebd1b20fa48d3b4b4f", size = 168902, upload-time = "2026-11-04T09:44:26.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/5c/50a0b0796ccb69b11934f7c5758051c08019bda2fafb417501109fffd569/psycopg-3.3.8-py3-none-any.whl", hash = "sha256:ca50af6072ba9ea6866f33021975230c2555d7bfdb9d8f94ca3af5c3d1bf2167", size = 216209, upload-time = "2026-11-04T09:37:35.491Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", version = "3.3.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.2.13" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/16/325f72b7ebdb906bd6cca6c0caea5b8fd7092c4686237c5669fe3f3cc7f2/psycopg_binary-3.2.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9e25eb65494955c0dabdcd7097b004cbd70b982cf3cbc7186c2e854f788677a9", size = 4013642, upload-time = "2025-11-21T22:29:43.39Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a6/f7616dfcab942d5ad6fb5ce8364148e22a4cd817340ac368b6a6bd17559d/psycopg_binary-3.2.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:732b25c2d932ca0655ea2588563eae831dc0842c93c69be4754a5b0e9760b38d", size = 4076666, upload-time = "2025-11-21T22:29:51.33Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f7/cddf75c43c967c9262afe6863275fdd2e5f877d98c379f5c3a21b6fa419d/psycopg_binary-3.2.13-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7350d9cc4e35529c4548ddda34a1c17f28d3f3a8f792c25cd67e8a04952ed415", size = 4639390, upload-time = "2025-11-21T22:29:57.614Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/f86f2e6413ac024b3a759fd446cc90c325a0d7403dce533bd419e1c41164/psycopg_binary-3.2.13-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:090c22795969ee1ace17322b1718769694607d942cef084c6fb4493adfa57da0", size = 4737745, upload-time = "2025-11-21T22:30:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/1a17c7176875d7e0a848710d87f13fdd3cc08724fa6bfcc43c72846f22b9/psycopg_binary-3.2.13-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9ac329532f36342ff99fc1aefdbb531563bec03c7bc3ae934c8347a7a61339df", size = 4419762, upload-time = "2025-11-21T22:30:05.401Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9b/5c7f8c90a3504c45ceadffa1f1f4b2fc8ce9e04494cf67d27dfa265e5681/psycopg_binary-3.2.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1db11a7e618d58cfb937c409c7d279a84cbb31d32a7efc63f1e5f426f3613793", size = 3878529, upload-time = "2025-11-21T22:30:09.493Z" }, + { url = "https://files.pythonhosted.org/packages/ea/37/37e7152e6b0813e68361768d1baf0e40d8ed0ac8091471641c2c88e0cec6/psycopg_binary-3.2.13-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5f5081b2cbb0358bb3625109d41b57411bf9d9c29762a867e38c06d974b245ee", size = 3560767, upload-time = "2025-11-21T22:30:13.88Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b2/929d8e15b8797486d160b797ce84a4d0251a9361f7f31e9b01b439608e3b/psycopg_binary-3.2.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5d466ac3a3738647ff2405397946870dc363e33282ced151e7ea74f622947c06", size = 3604456, upload-time = "2025-11-21T22:30:18.392Z" }, + { url = "https://files.pythonhosted.org/packages/c7/74/4d4e7481bc717bbe3de689c4d40439d4e1be07df989da2c38140298cbae5/psycopg_binary-3.2.13-cp310-cp310-win_amd64.whl", hash = "sha256:087acf2b24787ae206718136c1f51bc90cda68b02c3819b0556f418e3565f2c3", size = 2910871, upload-time = "2025-11-21T22:30:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/06/f5/fc70804a999167daf5b876107b99e8fe91c3f785a31753c0e3e7b93446ba/psycopg_binary-3.2.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9cfe87749d010dfd34534ba8c71aa0674db9a3fce65232c98989f77c742c9ce7", size = 4013844, upload-time = "2025-11-21T22:30:25.985Z" }, + { url = "https://files.pythonhosted.org/packages/07/87/857639681f5dfcd567aaf199fe4e5b026a105b0462a604f4fb7eda0735d8/psycopg_binary-3.2.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8db77fac1dfe3f69c982db92a51fd78e1354fa8f523a6781a636123e5c7ffcde", size = 4077002, upload-time = "2025-11-21T22:30:29.539Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1d/2cb7af6a31429b9022455c966d8408a2b5a19acd3de7610402381518e8f7/psycopg_binary-3.2.13-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cbbac4cd5b0e14b91ad8244268ca3fc2f527d1a337b489af57d7669c9d2e1a24", size = 4637181, upload-time = "2025-11-21T22:30:34.126Z" }, + { url = "https://files.pythonhosted.org/packages/28/bd/ffde1ac7e6ab75646c253fbe0378772fb6f0229af8a05cd9862ee8aad0f0/psycopg_binary-3.2.13-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a146f0a59a7e3ca92996f8133b1d5e5922e668f7c656b4a9201e702f4cf25896", size = 4737775, upload-time = "2025-11-21T22:30:38.408Z" }, + { url = "https://files.pythonhosted.org/packages/c2/74/3702732d01639c97943d56ec26860357dfacda0b5a708e82e794d07f499c/psycopg_binary-3.2.13-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:27150515de5f709e4142429db6fd36a1d01f0b8b17d915b5f7bb095364465398", size = 4421537, upload-time = "2025-11-21T22:30:42.696Z" }, + { url = "https://files.pythonhosted.org/packages/f2/8c/915a899857c2211196aa7f1749ba85bed421afaf72f185a0eb91e64ba550/psycopg_binary-3.2.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9942255705255367d94368941e3a913b0daf74b47d191471dbe4dc0de9fbc769", size = 3877500, upload-time = "2025-11-21T22:30:47.064Z" }, + { url = "https://files.pythonhosted.org/packages/36/d9/46060c183413bf62d47df98d7e3b30ab561639bcb583c3796cca30dafa43/psycopg_binary-3.2.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:75ebc8335f48c339ec24f4c371595f6b7043147fe6d18e619c8564428ab8adaf", size = 3560186, upload-time = "2025-11-21T22:30:54.522Z" }, + { url = "https://files.pythonhosted.org/packages/56/cf/2987689614632898e4861e4122cd41937ea9b5afcbe3c3061c7265bfa6de/psycopg_binary-3.2.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6fe2982a73b2ea473c9e2b91a35a21af3b03313bed188eccbcde4972483ac60a", size = 3601117, upload-time = "2025-11-21T22:31:01.218Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ef/df7fa8a47ef47d08af8a792343811a98bc7ab48f763560fc1d5acc1f28af/psycopg_binary-3.2.13-cp311-cp311-win_amd64.whl", hash = "sha256:6a50db4661fae78779d3cc38a0a68cabc997ca9d485ec27443b109ef8ac1672a", size = 2912873, upload-time = "2025-11-21T22:31:05.473Z" }, + { url = "https://files.pythonhosted.org/packages/49/9e/f90243b3d0d007a89989b013b0eb3e78ac929fed4eb40a2b317452abafe1/psycopg_binary-3.2.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:223fc610a80bbc4355ad3c9952d468a18bb5cd7065846a8c275f100d80cd4004", size = 3996285, upload-time = "2025-11-21T22:31:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/12/42/7d55f515ee3e2ced5ff9bc493fb2308f5187686b6d9583cd6a9c880d2053/psycopg_binary-3.2.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67f06a68d68b4621b6a411f9e583df876977afa06b1ba270b1b347d40aa93fc", size = 4070567, upload-time = "2025-11-21T22:31:12.31Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a8/ead4de04d8cf5f35119a75a8dd92fa4a2ec8a309b1aa58855f64616c03d7/psycopg_binary-3.2.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:082579f2ae41bdabe20c82810810f3e290ac2206cccf0cb41cf36b3218f53b3c", size = 4616833, upload-time = "2025-11-21T22:31:16.614Z" }, + { url = "https://files.pythonhosted.org/packages/26/2e/4af6ab69ade7d67d31296f88c79c322a3522564e30b3f1458f19e74d67c3/psycopg_binary-3.2.13-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ff7df7bd8ec2c805f3a4896b8ade971139af0f9f8cf45d05014ac71fe54887be", size = 4711710, upload-time = "2025-11-21T22:31:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/9a/31/bdbd6b2264bb7ae5fe8b775c5524da73329d8888c6137fd8b050ff9cabbc/psycopg_binary-3.2.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f1189dc78553ef4b2e55d9e116fc74870191bc6a9a5f4442412a703c4cc6c3b", size = 4401656, upload-time = "2025-11-21T22:31:26.842Z" }, + { url = "https://files.pythonhosted.org/packages/33/c5/8fd8f96450e4ef242022c9a588305e3dc7309c34bc392a9b4c2da60854b1/psycopg_binary-3.2.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0ef8ed4a4e0f7bf5e941782478a43c14b2b585b031e2266dd3afb87be2775d95", size = 3851747, upload-time = "2025-11-21T22:31:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/4a/47/406d102ae49d253f124644530f1e5b3fd2f92aea59d4f9b8dd1c71cf8e0f/psycopg_binary-3.2.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:de06fc9707a49f7c081b5c950974dd6de3dc33d681f7524f0b396471f5a4a480", size = 3524796, upload-time = "2025-11-21T22:31:34.377Z" }, + { url = "https://files.pythonhosted.org/packages/45/6f/a89be8aee27a5522e97dbcb225fe429c489acdf0bb25fc0fadb329dfb39f/psycopg_binary-3.2.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:917ad1cd6e6ef8a9df2f28d7b29c7148f089be46ac56fe838f986c0227652d14", size = 3576536, upload-time = "2025-11-21T22:31:38.06Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f8/c924c7dc792c81bf6181d7d4eeb613c8b2151b3a208f95cedec3c1a25ba3/psycopg_binary-3.2.13-cp312-cp312-win_amd64.whl", hash = "sha256:b53b0d9499805b307017070492189e349256e0946f62c815e442baa01f2ea6c5", size = 2902172, upload-time = "2025-11-21T22:31:41.256Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/ef37bb44dc02fcc6c0a3eeb93f4baaac13bcb228633fe38ad3fb5a3f6449/psycopg_binary-3.2.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbae6ab1966e2b61d97e47220556c330c4608bb4cfb3a124aa0595c39995c068", size = 3995628, upload-time = "2025-11-21T22:31:45.921Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ad/4748f5f1a40248af16dba087dbec50bd335ee025cc1fb9bf64773378ceff/psycopg_binary-3.2.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fae933e4564386199fc54845d85413eedb49760e0bcd2b621fde2dd1825b99b3", size = 4069024, upload-time = "2025-11-21T22:31:50.202Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/f02ec6bbc30c7fcd3b39823d2d624b42fae480edeb6e50eb3276281d5635/psycopg_binary-3.2.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:13e2f8894d410678529ff9f1211f96c5a93ff142f992b302682b42d924428b61", size = 4615127, upload-time = "2025-11-21T22:31:56.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0d/a54fc2cdd672c84175d6869cc823d6ec2a8909318d491f3c24e6077983f2/psycopg_binary-3.2.13-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f26f7009375cf1e92180e5c517c52da1054f7e690dde90e0ed00fa8b5736bcd4", size = 4710267, upload-time = "2025-11-21T22:32:04.585Z" }, + { url = "https://files.pythonhosted.org/packages/9d/b7/067de1acaf3d312253351f3af4121f972584bd36cada6378d4b0cdcebd38/psycopg_binary-3.2.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea2fdbcc9142933a47c66970e0df8b363e3bd1ea4c5ce376f2f3d94a9aeec847", size = 4400795, upload-time = "2025-11-21T22:32:08.883Z" }, + { url = "https://files.pythonhosted.org/packages/64/b5/030e6b1ebfc4d3a8fca03adc5fc827982643bad0b01a1268538d17c08ed3/psycopg_binary-3.2.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac92d6bc1d4a41c7459953a9aa727b9966e937e94c9e072527317fd2a67d488b", size = 3851239, upload-time = "2025-11-21T22:32:12.333Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/0541845364a7de9eae6807060da6a04b22a8eb2e803606d285d9250fbe93/psycopg_binary-3.2.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8b843c00478739e95c46d6d3472b13123b634685f107831a9bfc41503a06ecbd", size = 3525084, upload-time = "2025-11-21T22:32:15.946Z" }, + { url = "https://files.pythonhosted.org/packages/83/ae/6507890dc30a4bbd9d938d4ff3a4079d009a5ad8170af51c7f762438fdbf/psycopg_binary-3.2.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2f63868cc96bc18486cebec24445affbdd7f7debf28fac466ea935a8b5a4753b", size = 3576787, upload-time = "2025-11-21T22:32:19.922Z" }, + { url = "https://files.pythonhosted.org/packages/9d/64/3d1c2f1fd09b60cdfbe68b9a810b357ba505eff6e4bdb1a2d9f6729da64c/psycopg_binary-3.2.13-cp313-cp313-win_amd64.whl", hash = "sha256:594dfbca3326e997ae738d3d339004e8416b1f7390f52ce8dc2d692393e8fa96", size = 2905584, upload-time = "2025-11-21T22:32:23.399Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b4/7656b3d67bedff2b900c8c4671cb6eb5fb99c2fc36da33579cac89779c25/psycopg_binary-3.2.13-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:502a778c3e07c6b3aabfa56ee230e8c264d2debfab42d11535513a01bdfff0d6", size = 3997201, upload-time = "2025-11-21T22:32:28.185Z" }, + { url = "https://files.pythonhosted.org/packages/e0/2e/3b4afbd94d48df19c3931cedba464b109f89d81ac43178e6a3d654b4e8d5/psycopg_binary-3.2.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7561a71d764d6f74d66e8b7d844b0f27fa33de508f65c17b1d56a94c73644776", size = 4071631, upload-time = "2025-11-21T22:32:32.594Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/107d06d55992e2f13157eb705ba5a47d06c4cf1bed077dff0c567b10c187/psycopg_binary-3.2.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9caf14745a1930b4e03fe4072cd7154eaf6e1241d20c42130ed784408a26b24b", size = 4620918, upload-time = "2025-11-21T22:32:37.357Z" }, + { url = "https://files.pythonhosted.org/packages/e1/47/a925620f261b115f31e813a5bfe640f316413b1864094a60162f4a6e4d67/psycopg_binary-3.2.13-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a6cafabdc0bfa37e11c6f365020fd5916b62d6296df581f4dceaa43a2ce680c", size = 4714494, upload-time = "2025-11-21T22:32:42.138Z" }, + { url = "https://files.pythonhosted.org/packages/46/33/bed384665356bb9ba17dd8e104884d87cc2343d16dffdfd9aaa9a159bd4d/psycopg_binary-3.2.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96cb5a27e68acac6d74b64fca38592a692de9c4b7827339190698d58027aa45", size = 4403046, upload-time = "2025-11-21T22:32:47.241Z" }, + { url = "https://files.pythonhosted.org/packages/41/88/749d8e8102fb5df502e2ecb053b79e78e3358af01af652b5dbeb96ab7905/psycopg_binary-3.2.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:596176ae3dfbf56fc61108870bfe17c7205d33ac28d524909feb5335201daa0a", size = 3859046, upload-time = "2025-11-21T22:32:51.481Z" }, + { url = "https://files.pythonhosted.org/packages/38/7c/f492e63b517d6dcd564e8c43bc15e11a4c712a848adf8938ce33bfd4c867/psycopg_binary-3.2.13-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:cc3a0408435dfbb77eeca5e8050df4b19a6e9b7e5e5583edf524c4a83d6293b2", size = 3531351, upload-time = "2025-11-21T22:32:55.571Z" }, + { url = "https://files.pythonhosted.org/packages/07/5a/d8743eb23944e5cf2a0bbfa92935c140b5beaacdb872be641065ed70ab2c/psycopg_binary-3.2.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65df0d459ffba14082d8ca4bb2f6ffbb2f8d02968f7d34a747e1031934b76b23", size = 3581034, upload-time = "2025-11-21T22:33:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/46/b2/411d4180252144f7eff024894d2d2ebb98c012c944a282fc20250870e461/psycopg_binary-3.2.13-cp314-cp314-win_amd64.whl", hash = "sha256:5c77f156c7316529ed371b5f95a51139e531328ee39c37493a2afcbc1f79d5de", size = 3000162, upload-time = "2025-11-21T22:33:07.378Z" }, + { url = "https://files.pythonhosted.org/packages/80/dc/3ea3fe5df19af323b4b78e0e98e073f8117b1336e5b6dc6978c067485019/psycopg_binary-3.2.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6d8d1b709509d0f8cb857acf740b5eccd5bd2fb208a5b20e895f250519a32459", size = 4015148, upload-time = "2025-11-21T22:33:47.539Z" }, + { url = "https://files.pythonhosted.org/packages/e1/28/a832b014974e7bda61b3c684afe5e47f70d5dc4471cbab90a41a7c2bdf6a/psycopg_binary-3.2.13-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2d45bc5f4335498d32a26c8f8c0bf9ce8c973c19e78a9ee77c031300fb361300", size = 4078197, upload-time = "2025-11-21T22:33:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/5c/8c/5962c876a8bba4a6f8ff941998577e8359c928c700d092893e10f97aa94e/psycopg_binary-3.2.13-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f062d725898bf6fc5cfc6349a0d08ee09f129deb14d7fcd5c30f9f1b349f39dc", size = 4638520, upload-time = "2025-11-21T22:33:57.568Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/b557ac96752da8fd4b0ff7a128d148e6809ce576a2add6156c91d55abe0a/psycopg_binary-3.2.13-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:915647b5bbbcde2bd464dc293eec4f74710fa71edc4f85aa6f6c8494a179dc9e", size = 4737730, upload-time = "2025-11-21T22:34:02.969Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9a/af2d96c0e711e90cf340a5f607911cd6df593fe1aec9c46644162161af18/psycopg_binary-3.2.13-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d3aec6e2f1cf4deb1b9a3ac287c0591479f3bd851d0a911d628f8c2c71c14f4a", size = 4421382, upload-time = "2025-11-21T22:34:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f6/f8135198a2c70ca663b55d44c6fc3beb4e36025679b541a9d489814f2ddc/psycopg_binary-3.2.13-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a56a8b1794cbf27ca04012ac2890d58cfc82b3b310c1dac4fa78fbf6f57e7440", size = 3879259, upload-time = "2025-11-21T22:34:17.706Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8c/3f778fc954f0b691941073a1d8b78c07219594135831cad32a739e4eee97/psycopg_binary-3.2.13-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4150a5e72f863be442d153829724109d83a76871d9bc801d6bb5b9c84b5b19b9", size = 3560475, upload-time = "2025-11-21T22:34:21.329Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/731d56c636155f210fbb00cdbb7498c0e04a21052415520da54ac96eca63/psycopg_binary-3.2.13-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:028b49eb465f5d263d250cfd4f168fdabb306d0bbd97fd66a8a1fd7b696a953c", size = 3605616, upload-time = "2025-11-21T22:34:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/8f/22/2619870c9ed44b5eaeae4f7706126754ccadde6319483cd4c490f5d13fbb/psycopg_binary-3.2.13-cp39-cp39-win_amd64.whl", hash = "sha256:532ea34f673148d637be65a96251832252e278540b39fbd683ef37e58ec361c1", size = 2912739, upload-time = "2025-11-21T22:34:29.069Z" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/fe/29b20b4aedb99989fe81127e2396a00379372c78b0f63de1a05a61007554/psycopg_binary-3.3.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2dcdad18f7e6b0241dafbaad3033ca5c12e07755418ece4c3a90e9665e434e", size = 4731825, upload-time = "2026-11-04T09:37:45.394Z" }, + { url = "https://files.pythonhosted.org/packages/00/55/1fa24d8d2bed9dd743239851a7fcba07afa54eeb6bd8a59bc9bd15ad9be7/psycopg_binary-3.3.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4332b7178062649e29d2770905f20e6a4b2dfc54581dcfcd919aeaed26291f4", size = 4780794, upload-time = "2026-11-04T09:37:52.682Z" }, + { url = "https://files.pythonhosted.org/packages/22/97/bd56c1a42692a75b1ca26fbb6c41b8913d79904cea303826ee932f816516/psycopg_binary-3.3.8-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b26fbeb64c3ec29d799c89709f9500bd3bfa452ae22bdac3e11c3bb15fb057dc", size = 5598713, upload-time = "2026-11-04T09:38:01.154Z" }, + { url = "https://files.pythonhosted.org/packages/7d/57/90a0c4eb6398cbd7d354d10e8108e0a64cfed69cf6bfbaa348b5a6583325/psycopg_binary-3.3.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f719b1b6498b3562843be83add8714c8967da95bb9aa626aae157a8ca31e4742", size = 5270332, upload-time = "2026-11-04T09:38:06.677Z" }, + { url = "https://files.pythonhosted.org/packages/bf/7f/d04780a1c8e797ca2d31d3c0c3595e961f505af2f556d4a33fa5c0c024e0/psycopg_binary-3.3.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f454d1d8e8071c7ec5fc10db6204334c36bba5ad30005c4b8c35859b1250ac6", size = 6866275, upload-time = "2026-11-04T09:38:12.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/15/a7b909602a816a10371901f55132495dc086095846b30a6bfcdba845bbd5/psycopg_binary-3.3.8-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1f65e8e84e13b34ef40011b30c7e6eb39578956c4a0f326a0a5a274a3d84f812", size = 5111623, upload-time = "2026-11-04T09:38:19.415Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e7/1d74197109b3bd5bba43fd8a8599af4850a87fd6609d58026e83d6ffdb59/psycopg_binary-3.3.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9353240f6e58d4a988f3ca842185f5349594153dcddd05e30f83c7d8badfbe0e", size = 4633111, upload-time = "2026-11-04T09:38:24.785Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/67d6d4a895abc48267417e16ec9b35bb89b785b6f20e9a4ba7b99b7e9cdd/psycopg_binary-3.3.8-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fc9b9922899a3ca6193fd1d96920329c10acb72d3fad0dbc87b1ecdd8a791c14", size = 4326824, upload-time = "2026-11-04T09:38:28.631Z" }, + { url = "https://files.pythonhosted.org/packages/57/21/206ed9d734bc738480886e6d0eda018293beb8787fad2e4760a030adcb28/psycopg_binary-3.3.8-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:382e3a1fcc7988cd05bfa68db7cc50189e785c05be212d27b524f0cc983fc77a", size = 4052820, upload-time = "2026-11-04T09:38:35.849Z" }, + { url = "https://files.pythonhosted.org/packages/a3/bb/be3cdfce99980f39aaf7285cf0f9dea4e590a074b822ab2a46a3d9dd2593/psycopg_binary-3.3.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d01495f37407899a699726710e26d6105654273a12afc8fe87784743b4802844", size = 4358286, upload-time = "2026-11-04T09:38:42.15Z" }, + { url = "https://files.pythonhosted.org/packages/48/0e/97e0222166b770500443dd224233cf7208ddb0964aa6bf1f2e4b7f1da0c7/psycopg_binary-3.3.8-cp310-cp310-win_amd64.whl", hash = "sha256:9a7b232d55ab7ba22be48487c5f0adb7a6941fd54b436bdbbb72df4e5a740fb2", size = 3635283, upload-time = "2026-11-04T09:38:49.413Z" }, + { url = "https://files.pythonhosted.org/packages/2e/04/674c4048729b5ad90d1036c7c6c4ce13d2e05ca3d16ed68368e00c092e71/psycopg_binary-3.3.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:42718d7e5732aa1feb0a7f0ff7e0404fd080528bd4694b40b31ef0b146eda8e1", size = 4731192, upload-time = "2026-11-04T09:38:56.473Z" }, + { url = "https://files.pythonhosted.org/packages/03/6d/98b98c95dae1c53e52dac2301093b89640afb178ebfed745dc8465a3d64d/psycopg_binary-3.3.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1126ff5eae645a304e6ca94e1c79cc19249fb54517b923ee5862badd2fabb334", size = 4779497, upload-time = "2026-11-04T09:39:00.917Z" }, + { url = "https://files.pythonhosted.org/packages/77/e9/8aa5b11a272d97c7240796c16bce5f98c3ab07fbb2643e60432fc446d0f6/psycopg_binary-3.3.8-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:281d0e891fa8c2bb231a97a5a57522cc2a9664c3f77b9fb007d2719bc1afd29e", size = 5602158, upload-time = "2026-11-04T09:39:07.903Z" }, + { url = "https://files.pythonhosted.org/packages/cd/33/28dde854cf3175e0e68609e8b3b5f4e3c40cdf14c08ae1f8d66930451191/psycopg_binary-3.3.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39bc3b42e6486ae8ace820df93b00dce4858338e8a428d14d8e71130e852bab1", size = 5271229, upload-time = "2026-11-04T09:39:14.207Z" }, + { url = "https://files.pythonhosted.org/packages/48/7b/f57955d6cb1a5b1b530faeb12df2eaa3f3eff14626d6862a7b5de4b49c59/psycopg_binary-3.3.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bedd0fbbbebe0c0f66b34c3f6be972ce38357ce3451510e1dcf5025f7011dba", size = 6864844, upload-time = "2026-11-04T09:39:19.686Z" }, + { url = "https://files.pythonhosted.org/packages/91/50/3fa8790d539f14e8e10f904fca22226555d7fff773854974d163cfa47bdd/psycopg_binary-3.3.8-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe26e72b87ca280e538f4f78d1a956df0ae6ed51aab73ccc4c1dcf6f25256438", size = 5111039, upload-time = "2026-11-04T09:39:24.07Z" }, + { url = "https://files.pythonhosted.org/packages/57/a4/12fda52aa11297ca0e3aec5b6433232a42dc8e6c29a2413b4470a7b69bd3/psycopg_binary-3.3.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a3925d0c67b0452db066d97d0452af012e7bd2fdf8e6ab4ab0392df8906a6e1", size = 4631127, upload-time = "2026-11-04T09:39:29.372Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6c/1fd9639c6f27f527ac991ad9cf827ddcdd524e0482085fd0d9acacba23d3/psycopg_binary-3.3.8-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d4de096987b9f7c346879e164637de91a66067378b1ee4b49f07d1bac0e2847a", size = 4324090, upload-time = "2026-11-04T09:39:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/f5/58/dd3a5fa5d23b42e0cb466f3d4a9e9759399dcf03a2a2b7a6a3e4cd67197f/psycopg_binary-3.3.8-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c3ac86af1a7ac65fe3461d831e8bc9fa62bc247ef1b73d58e9dd4ed406be4bfd", size = 4051699, upload-time = "2026-11-04T09:39:38.424Z" }, + { url = "https://files.pythonhosted.org/packages/e6/01/9b20b20d240c075b5fb7e4b2baf4c70cdedd34806a87de65537ec809da19/psycopg_binary-3.3.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:09676448a9bbc5c09ce8d578a6958a0bb8175e63c94edf6d88a62d2b07fadd23", size = 4354989, upload-time = "2026-11-04T09:39:43.509Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/e2b149237e5c7ea559deba7ffa9527cb11866424933c1418d22fc5f81590/psycopg_binary-3.3.8-cp311-cp311-win_amd64.whl", hash = "sha256:ed65d1b32a7f9684ab682296ac7cac1dd972bb8d86c9336753bc915efc3515f4", size = 3636517, upload-time = "2026-11-04T09:39:49.676Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e8/10db627d04a32afbba6e4a6aaf192c0ae88321e44669c0224ee88e11ad68/psycopg_binary-3.3.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a09aa2ba7927e9b11bd5bddb6d33cec274235d6f54f1ab777ed71c932cfc8d72", size = 4710638, upload-time = "2026-11-04T09:39:59.55Z" }, + { url = "https://files.pythonhosted.org/packages/67/f5/ddfd762133fda147c8779f3014dfa5404868aea1d3f4cc6f6bc5b8c3a076/psycopg_binary-3.3.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e0972d8de5a0d855d76624f559a6ce5dd068fb4bbfa01e1d7cf929e6800dc4f0", size = 4773113, upload-time = "2026-11-04T09:40:04.058Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f5/d9944976536d4f94ce36f4cc9eec43be66a227159d91cab6cec2c36fe109/psycopg_binary-3.3.8-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:41c31b175084a85232b6ea5f8eb474491b6c963302c69b46fa2ed60afb888b62", size = 5561093, upload-time = "2026-11-04T09:40:09.27Z" }, + { url = "https://files.pythonhosted.org/packages/c7/04/aef0b229adc200259d3aec4c8ba17a2ff233505dec8712f37f7168e45c06/psycopg_binary-3.3.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a7cdba018e4decf3837793c1e18dbe3dd71709adef54892ab83d81c00e866dc3", size = 5241603, upload-time = "2026-11-04T09:40:14.778Z" }, + { url = "https://files.pythonhosted.org/packages/95/f3/eda400c9b76805996e3d7197fc0b0b3c7068311f623b72f71f20f1bfe620/psycopg_binary-3.3.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:804041b79916b0e8021a1130650d724741eff1e81ccec8133abf357b4d284a24", size = 6838293, upload-time = "2026-11-04T09:40:22.645Z" }, + { url = "https://files.pythonhosted.org/packages/78/7a/dfc5c75661c4677b853f91c52273904b1ab24ae8272e14bb74644dc74d10/psycopg_binary-3.3.8-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9daff4d6257969b02678943d62bfd83b971004f6efef68d5b767ac69c2222751", size = 5073947, upload-time = "2026-11-04T09:40:29.461Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/fedb9ddef0ae0ee03bcbb4f2ced765f3cf7a82a0836e4bc175b775949ecf/psycopg_binary-3.3.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e8f1addbaec62c9e2bf5b3ae4b32a20c793c06de7d57471f6509a127f1c902f4", size = 4602727, upload-time = "2026-11-04T09:40:36.744Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ac/bd948283a127e03df0d89d19b81c02de54b3176b9d068ebe2a9ac0acf628/psycopg_binary-3.3.8-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5f51d97b67276e90cda022ad363eca61dbe855dbb5342e358469006d4b5254df", size = 4283009, upload-time = "2026-11-04T09:40:42.027Z" }, + { url = "https://files.pythonhosted.org/packages/1c/8a/d8fdd24b3356d188e84892663d403d4f2ae02326f342b0806cfdd9e0433e/psycopg_binary-3.3.8-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aa7af0d2d1194b65b8c4d0056c5607de6c38fb6a295f04087d17e1c9d2d11fba", size = 4013053, upload-time = "2026-11-04T09:40:47.155Z" }, + { url = "https://files.pythonhosted.org/packages/e7/fc/ca06d048860f210d84b2ea7d49d15ed8780143454619c371d91868077d05/psycopg_binary-3.3.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f09cb4e7744522d71048a8998ff841c7af942a61ffbd4142a140b42d89b950fe", size = 4325780, upload-time = "2026-11-04T09:40:52.139Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c7/d96e8ff851d1f83e5cd6ec57e9a7f6eb9fb526ecdb4bbbf017a5066e9199/psycopg_binary-3.3.8-cp312-cp312-win_amd64.whl", hash = "sha256:ec6773395189417c92102b34c77ed299f6111fbbc37d50d4fa12ab819f763669", size = 3619067, upload-time = "2026-11-04T09:40:59.475Z" }, + { url = "https://files.pythonhosted.org/packages/04/27/3b2dc3eb98d32ac87746fb327e889404e611fc017397b3b83057de38a701/psycopg_binary-3.3.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d483014dde0bd5404801aa91ebbd7912006065dd8d244a962f228fe44bfc08b", size = 4715357, upload-time = "2026-11-04T09:41:07.346Z" }, + { url = "https://files.pythonhosted.org/packages/72/fb/2bee345580b59e749078da581d7b0fc16ec9dec53a57225d698da391d1bc/psycopg_binary-3.3.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6ff02f23b7d95de68f42d5ebe91374540d3d31851d83d9cd37e6e71a5dbd0bcd", size = 4775924, upload-time = "2026-11-04T09:41:11.838Z" }, + { url = "https://files.pythonhosted.org/packages/53/4f/b1569176285bc74b031eb287bc9dba75fcb22aa3be9a3faee5fa593b5f88/psycopg_binary-3.3.8-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:31dd2597df303d22a2cabf73c40bb05afaf4ca1394e22935b97f627d4968c87d", size = 5561131, upload-time = "2026-11-04T09:41:18.604Z" }, + { url = "https://files.pythonhosted.org/packages/94/bb/89ce891e7897638df5eb34c3e7c36143c59933fd6602e61a7b9b8e905644/psycopg_binary-3.3.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2f72c18d75d16500fe2f7f300945d68fba9dbb883aef20ba4be0c386afbbf2e9", size = 5243034, upload-time = "2026-11-04T09:41:27.936Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/a676f32b29096651f9725e9cae78a033bcf88697df4e1d31b7b77589be6c/psycopg_binary-3.3.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00c226f718901ce022bcee2e0779dc2f0a6d8bcedd5a3818e21ff25564efaa97", size = 6837999, upload-time = "2026-11-04T09:41:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/dd/00/e83def05e558d011bcddb2cba10d7087b92980a4abc387f4c58cad75d6b4/psycopg_binary-3.3.8-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3a8420317a3b3910a08b77a952ef8f8051da61622c6a2e03fc51b75761c6acf", size = 5076374, upload-time = "2026-11-04T09:41:42.432Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/46a29692f7b87d96efc675087fe4495d01d2811720c16abce351ec78547a/psycopg_binary-3.3.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a565d8446b59d26dcf75b08d3ccd4e85b750edba9e53a396cb7a4652d7cbc9e8", size = 4603132, upload-time = "2026-11-04T09:41:49.434Z" }, + { url = "https://files.pythonhosted.org/packages/eb/cc/e3bd0dc83e52aff75bb8131f200e336f6d8a5642de60cc96694eb53540af/psycopg_binary-3.3.8-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6891b6d9d25103c31411b50f83f9a8aba63f4cbcc63b009f461c115bfaf7b78d", size = 4282092, upload-time = "2026-11-04T09:41:54.868Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e1/db358ef11a5e5b7590aba3dfa2ef7af685210e27f37a2ae3b93c5af99bc4/psycopg_binary-3.3.8-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7d2b484e8ced7c3292e811d28087814467aecd3ef19badb64ed45a0f5fd78bf8", size = 4014488, upload-time = "2026-11-04T09:42:03.348Z" }, + { url = "https://files.pythonhosted.org/packages/0d/6d/45f8401f1b1dd65303892a786d190814279d212a663f840572da3b73084d/psycopg_binary-3.3.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:522acdc8a71a042b407851658eb1da85fe09c78b222b2233faef7da3385a757b", size = 4326427, upload-time = "2026-11-04T09:42:09.224Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/3db3e063f05f50641d3e8483132507504b781c8f045f0c3ef883adf73baa/psycopg_binary-3.3.8-cp313-cp313-win_amd64.whl", hash = "sha256:e42c0e37eaed1ff03c24008561bca218eae8ec5ca4405a37897c6245ead8d612", size = 3618316, upload-time = "2026-11-04T09:42:13.761Z" }, + { url = "https://files.pythonhosted.org/packages/7a/67/7da6c17ce91cc8f991c104d7d557396d37bd2394436d446af87493e8bed7/psycopg_binary-3.3.8-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e09cc11a367cbb147bacec0a41d748d22245e322827911d9c82b6066a8ba2059", size = 4723560, upload-time = "2026-11-04T09:42:20.14Z" }, + { url = "https://files.pythonhosted.org/packages/f3/0c/56a7a35b304a7ae9f2796f76dd76a847d34f0cdaa76f0945294c06d13220/psycopg_binary-3.3.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bf9388216da2069ca0dbad58479bb83e4b979ed35c4a80d247399ac93361417c", size = 4786289, upload-time = "2026-11-04T09:42:25.777Z" }, + { url = "https://files.pythonhosted.org/packages/17/d1/99852fa4ebae1956c664be13758cf98219e9bf9613d58b9f4b21301dd1a9/psycopg_binary-3.3.8-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78f92c5c9c9177b247b0f72f03412bc829185786525a8cf8742460066762a7d1", size = 5573883, upload-time = "2026-11-04T09:42:30.487Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/b0e38a0f80439e3b4a4ce52d1421ee5c6aa2803a43c50d32cfd16efb6df9/psycopg_binary-3.3.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:710183a2e29c72050c3a447f9f566de27c6efea9da40bf7b800381ac57026204", size = 5251968, upload-time = "2026-11-04T09:42:37.21Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b3/8bc56251ee11c9fcbf83fd87970116d9997834bb7ff8b9d1b8e23b468581/psycopg_binary-3.3.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df35063b88af16c08bb8960aac9882cce620aea478dfec5a67cff397b575b97d", size = 6851902, upload-time = "2026-11-04T09:42:43.903Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c7/64fd282c7a16dbb56af75f336c58e16c7e00be147d8efb93eeec6a4d4def/psycopg_binary-3.3.8-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a63be4f745b778654e3c438d1b0e31bcf5ce265151c6982d5c6d5d286bae84c0", size = 5085640, upload-time = "2026-11-04T09:42:48.945Z" }, + { url = "https://files.pythonhosted.org/packages/80/da/88d70f65c54a828f3d89597749e6c1b90991297e34de5f50b0f8f394022b/psycopg_binary-3.3.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ced3499d480a979b956536b3f71a4736451bf3a976e4e400fc5c4b8dd3f43b23", size = 4617446, upload-time = "2026-11-04T09:42:53.985Z" }, + { url = "https://files.pythonhosted.org/packages/e8/fe/0bdcc827e2fdb4a63e9516ad9e6e3989c39076796074ee752e0fd5c7e2bd/psycopg_binary-3.3.8-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bf96efb4530605813b30d664f5d3b510540d89434a8e89dd7c62fb2d4fa4278f", size = 4299889, upload-time = "2026-11-04T09:42:59.846Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/d870616fd18f371c710a08fee92d1f7c663753e992e03472b84954fa8a82/psycopg_binary-3.3.8-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0fd106e47b7d1f82744b23b6dd31856435e46c3d1b3d4972325d76f2e7a3d771", size = 4025817, upload-time = "2026-11-04T09:43:07.8Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/af3b881b42954a5cf406b084588dd7e2fd069bb998beeef2ab5057d4cbec/psycopg_binary-3.3.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5deec7746acf1c742572b8bf8599f2f61a38c9925015d77254d87d3781169b3f", size = 4337790, upload-time = "2026-11-04T09:43:13.097Z" }, + { url = "https://files.pythonhosted.org/packages/00/72/132dd54feeac60ecb77eae04bac76cdcf84f8fc1e8e7cae596cd2a9d43c9/psycopg_binary-3.3.8-cp314-cp314-win_amd64.whl", hash = "sha256:6d899f361effad9a23d90940b6b55683bc87ed2d457ef64a7fa76fee7c3b9dfc", size = 3715672, upload-time = "2026-11-04T09:43:17.504Z" }, + { url = "https://files.pythonhosted.org/packages/22/85/7589ec7690974b56b5cd2c5c00174f9b78f958fb6a381317a33620696380/psycopg_binary-3.3.8-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:419a52eb62bdc0bf9eff80039329435c55a1943b1fa12f4d0cbdc45b5f856c5b", size = 4722790, upload-time = "2026-11-04T09:43:22.917Z" }, + { url = "https://files.pythonhosted.org/packages/49/cd/7a01cfa27b2f062727d3abe82726915cc45789a4649bdab6f7740b779348/psycopg_binary-3.3.8-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f31dea98f8c5c6c60e8e049388f24eec14088518e2ded0f892d96c785401e688", size = 4785164, upload-time = "2026-11-04T09:43:31.258Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ae/06211024de518c5a5d02733fba9639a062dc41f1f322e273ad611973bb3b/psycopg_binary-3.3.8-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5fd40b5ced3f8b458024ea434f2dfee7aa62a65663089bde26c41302f6c31b2e", size = 5571610, upload-time = "2026-11-04T09:43:39.714Z" }, + { url = "https://files.pythonhosted.org/packages/e5/48/82a13d5b04fb046582b87f0651252f66dc5e1cc3183d6807aff1ea126189/psycopg_binary-3.3.8-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc134798cd646a15326004bbeff4c2719e000a9ac52f6b893a767f7c5276e24b", size = 5254032, upload-time = "2026-11-04T09:43:45.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/24/01ddf5382fe55372f85f174e9531bdcc5350a6c9b980e25e19653d038bf8/psycopg_binary-3.3.8-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da101085aa9f74817e48fc96dda859528fc52fabcf81e27c3640d1df17dd6dcb", size = 6851760, upload-time = "2026-11-04T09:43:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/51/42/192a69f42c797d8dc09ebd97f69c2c3f745abba810379df83c38490fc428/psycopg_binary-3.3.8-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bb88457e8357a8709ab04f542c07361a816793f012a1c9818a4edd86d02cde4d", size = 5093186, upload-time = "2026-11-04T09:43:56.269Z" }, + { url = "https://files.pythonhosted.org/packages/82/73/a420dc5c2888d5c91bd40ddd7a8a604b622f3d883048a6b92e5c33ac1e9b/psycopg_binary-3.3.8-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:eb4cd3f0ad6ebcf2baeb94f93d87138044976b57b7113b6321ac3c5cf70a8130", size = 4618178, upload-time = "2026-11-04T09:44:01.001Z" }, + { url = "https://files.pythonhosted.org/packages/29/57/b30555a448fcc60607cbee68880a4126786680e71b7cc25e0a836b31da8b/psycopg_binary-3.3.8-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:9199be795608cbd3e6cc39b21c19e6043c44bc15d5a1d28f1df379830c553dd5", size = 4297173, upload-time = "2026-11-04T09:44:06.939Z" }, + { url = "https://files.pythonhosted.org/packages/48/4b/a6ead08df35feb2f5e10897e33c1ba26809f85365a7c2a0efa244acf9008/psycopg_binary-3.3.8-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:6bde86fb307fe1698fbbfeaa96130d4825f265afbeff217b959c1905b86f924d", size = 4032823, upload-time = "2026-11-04T09:44:15.209Z" }, + { url = "https://files.pythonhosted.org/packages/68/8a/7ff05b083c676024beb549863a19d59b205931010ec7f3a12aadf5de9901/psycopg_binary-3.3.8-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c2154bd84cf20148d9a59e3ade04f0f754462b96cfac91d54d76ef33af7946e5", size = 4341050, upload-time = "2026-11-04T09:44:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/58ab2cf0a6da11d5c2b00d524f5d5b114e2e6359e629d34360cf57a22e20/psycopg_binary-3.3.8-cp315-cp315-win_amd64.whl", hash = "sha256:0332e83c2e78b1c8c00c1095688f9b332bdde9d5e9d61b8ce1cdcaf716a19b9e", size = 3716321, upload-time = "2026-11-04T09:44:24.631Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "annotated-types", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pydantic-core", version = "2.46.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "typing-inspection", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic" +version = "2.14.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "annotated-types", version = "0.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pydantic-core", version = "2.50.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, + { name = "typing-inspection", version = "0.4.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/fc/1203d011145c98ddce3cd2f505bb54375ef8d43223053df6bfcf3c746f46/pydantic-2.14.3.tar.gz", hash = "sha256:1054f237f4d4733e5a30e1e2333bd33a2004ab9ab1510bd053a9a31bb48e232d", size = 925636, upload-time = "2026-11-05T09:17:04.686Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/b8/b864503789ef5c58597d2a7b4adbee62c26440ab1e682581fc6838df8947/pydantic-2.14.3-py3-none-any.whl", hash = "sha256:3f7b347782ab7e4cf4c798496ae3a82787d9f21ec12676a9c564355eeef8daa8", size = 494071, upload-time = "2026-11-05T09:17:02.426Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/6b/8f79692844269427abb3e4dd9e68edfcbe65ae25527d99183214de716c59/pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6", size = 2076533, upload-time = "2026-08-28T09:57:35.421Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d0/c787604c71c2bdcda1a5656942fc822cd0f9cd879b9484bb84fc42172703/pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615", size = 1924650, upload-time = "2026-08-28T09:57:37.944Z" }, + { url = "https://files.pythonhosted.org/packages/4a/77/ca2f8e997d9bfdb32205297aff38f210f398822d895b1af1b59fd9df9c13/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb", size = 1951261, upload-time = "2026-08-28T09:57:39.339Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/bd12e1a9255df4edee00353778e2614b5346265d51e1567ab72153e803a2/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b", size = 2021808, upload-time = "2026-08-28T09:57:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/f7f312751ebc6d6767da91964a9c7954c18e226a1720ab234e3dfb9d6c17/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6", size = 2196184, upload-time = "2026-08-28T09:57:42.275Z" }, + { url = "https://files.pythonhosted.org/packages/3d/93/ce93aa030ab6bac4683ba8861e7baad89dd24b02e66b8801a0e4f6a00311/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793", size = 2238212, upload-time = "2026-08-28T09:57:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/34/a1/c8e6b66f499f510752c07a092dfe27621f9c255635e59d38704b5681c35a/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b", size = 2064073, upload-time = "2026-08-28T09:57:45.613Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/605e2b127ee30dbf4b1da9da4843587cf2b2d16486c241cc7a5be2d2c1bd/pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461", size = 2093102, upload-time = "2026-08-28T09:57:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f7/1ab28093c09032ddce7c92c7a55d503b6ecd70f42c32492946c1cb5477b1/pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736", size = 2133452, upload-time = "2026-08-28T09:57:48.362Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/47c79b756f12f85e8b0fbdb2b495f6b6eb32e6c98a4beae7a570a0b7c63c/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3", size = 2146477, upload-time = "2026-08-28T09:57:49.74Z" }, + { url = "https://files.pythonhosted.org/packages/13/5c/79fc00cb8f651d6061991de8d7cedf1c78c73cbd4862c42ef418f03b8bfa/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f", size = 2300832, upload-time = "2026-08-28T09:57:51.639Z" }, + { url = "https://files.pythonhosted.org/packages/b4/72/dd1a29853cf6d22a1ebd9e3baf0239cbc57d2d16caff36a89e38eb9b1db3/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1", size = 2320505, upload-time = "2026-08-28T09:57:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/ba4a8e06a9ddad0b4caf69cfaeecc0fbfcec20473bd808f5127fd16491c4/pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069", size = 1956853, upload-time = "2026-08-28T09:57:54.592Z" }, + { url = "https://files.pythonhosted.org/packages/f2/94/205ed9d7ddaf44acd489889708ea124a3f41bdb42c141c8684d528ad0e7a/pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d", size = 2042551, upload-time = "2026-08-28T09:57:56.017Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/96/cc/4c88abc035cc0d8b2646a715d8c4145fad7d95817eb5f18297066b21e20e/pydantic_core-2.46.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed", size = 2078970, upload-time = "2026-08-28T10:00:18.938Z" }, + { url = "https://files.pythonhosted.org/packages/b4/59/fa3ef009cc1b2ca3753fd6869ee461b0b5b67c420cf659e32a12be027a6a/pydantic_core-2.46.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0", size = 1917185, upload-time = "2026-08-28T10:00:20.891Z" }, + { url = "https://files.pythonhosted.org/packages/15/5e/b3d8901f9775ad928077c3155c36f56fc1c813285e3986ed736a5fbf538e/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655", size = 1955266, upload-time = "2026-08-28T10:00:23.135Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9e/5522b09d12e8720013f2e4ac174999f40a05501bd15ad2bfa197bc136198/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a", size = 2023466, upload-time = "2026-08-28T10:00:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/89/2a/a5267bf2c6c7ded3f282b315e5f0cf2c58008c15b917a652fc32f92d6775/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d", size = 2198448, upload-time = "2026-08-28T10:00:27.676Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/f72c192aba23924065946728e4fba96f73939b90e5aa4f7d41e728aea8d4/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8", size = 2240121, upload-time = "2026-08-28T10:00:30.168Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/0c0cc24149429c030bef1a5c1776150e7e61fcbbfc068c6d1f9de90eb259/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf", size = 2067262, upload-time = "2026-08-28T10:00:32.285Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/a2b8690a11d909d9ec9c4eb4d084b4d2e1b227e9b2e74f5926cd39096245/pydantic_core-2.46.5-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464", size = 2095116, upload-time = "2026-08-28T10:00:35.556Z" }, + { url = "https://files.pythonhosted.org/packages/bf/7e/d3088a2717b7bb316d8d0e64a4b0caf994769e88c56df79df547d75c1dc0/pydantic_core-2.46.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64", size = 2134727, upload-time = "2026-08-28T10:00:37.829Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a4/55a9e0ef61cfd1cbf4289059eb68a3eab765fca8ead6f9991d7de027d42e/pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168", size = 2147932, upload-time = "2026-08-28T10:00:40.123Z" }, + { url = "https://files.pythonhosted.org/packages/2d/25/d2fbc9d59f91f6c50c0d2ec032041c5e3295d68325ade06ec93fa82da43c/pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e", size = 2301528, upload-time = "2026-08-28T10:00:42.339Z" }, + { url = "https://files.pythonhosted.org/packages/9d/76/eccc0528d1421e298b42f85650cf021f0f7c42f502c7e58808db4a672bdb/pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13", size = 2322431, upload-time = "2026-08-28T10:00:44.399Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0c/ffab5a9a0fb82825c44f00dea8ec9d540d2e1e4ab2f1c4f0c32bb8b37fd9/pydantic_core-2.46.5-cp39-cp39-win32.whl", hash = "sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7", size = 1958681, upload-time = "2026-08-28T10:00:46.786Z" }, + { url = "https://files.pythonhosted.org/packages/86/89/8bb47660fed8c16adf1aae301ba149442e8fd220c126bbea2d24b987abb8/pydantic_core-2.46.5-cp39-cp39-win_amd64.whl", hash = "sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0", size = 2046649, upload-time = "2026-08-28T10:00:48.99Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/0f/34f9ce4ba701dd0b815033ef94d99b56f66200e578ee6052aa3ac26f064e/pydantic_core-2.50.4.tar.gz", hash = "sha256:aead3b74b68396bf0feab5d57e050b730ded7df55853c1e048a87e00aaf033c6", size = 522249, upload-time = "2026-11-05T09:15:26.774Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/82/047d1c7dc4ec15130bbb3299d9e3cb85be945bb14db54e5ad5f1c91b5e75/pydantic_core-2.50.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:83d2beac85b757bfc404c5c0612df51c4ef33bbb7be65cf4a96b4da7cbc4ff4f", size = 2172546, upload-time = "2026-11-05T09:11:15.221Z" }, + { url = "https://files.pythonhosted.org/packages/95/ad/36732c76b5ad87647725681c8259376dddae8db9c3558e7cd99c25c7a9e1/pydantic_core-2.50.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:76e0065998159244ff4c097a6c05ff08019c6fbb6e597a85846e73044f0215b3", size = 2131205, upload-time = "2026-11-05T09:11:18.084Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ac/8550a2ac336d3063759a73802f5c045d073125aea44927ba0fa790c55b0d/pydantic_core-2.50.4-cp310-cp310-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:25e7e1080f9f7ce7cd9d114b31470ce912771bd02f38c824721d3c35d312abff", size = 2284431, upload-time = "2026-11-05T09:11:20.005Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d6/1a14b5da27def1db17274c215d4ab62c380b600689c4e54c4dc02c1b0cba/pydantic_core-2.50.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c4c5a1770ecfe8bdb6c6f09f8d81ea4de04f7efb012fac1efa4ad315dccc1fd1", size = 2131058, upload-time = "2026-11-05T09:11:21.561Z" }, + { url = "https://files.pythonhosted.org/packages/01/de/075e68a5dcf8fc64ffad92740512dd9cb81ca7c29c5ca32de89cfbe0447d/pydantic_core-2.50.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2f6767122b37c780bb756a720a4eef2bc6a050a0a1143428834b3cbc89448bd2", size = 2216460, upload-time = "2026-11-05T09:11:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ad/b04b1b589495c0678e2a721ad07f95be47dbeb789fb50babc0e8ea7ed964/pydantic_core-2.50.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:86c7c4615634b5c57a79770f5b738354e45ae219a89949aeae84f19169a05283", size = 2403715, upload-time = "2026-11-05T09:11:25.105Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c1/9c8a9f6b42bd6080cde7143b9c3685cfa0859cea4e4860bd126df22602e2/pydantic_core-2.50.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6c7d7b0b596319e9ce79ef96ce57a14054a2eac629c0588a0539187a218be799", size = 2454737, upload-time = "2026-11-05T09:11:26.787Z" }, + { url = "https://files.pythonhosted.org/packages/e3/df/cddc0729c366460763831e6087e88fc0197c78f34fc79ac993a41b5d0242/pydantic_core-2.50.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed9c0b6f236648dbf30d57e9f57e1f74878caafa04833a19f529b5ba5164b7a8", size = 2257210, upload-time = "2026-11-05T09:11:28.805Z" }, + { url = "https://files.pythonhosted.org/packages/7a/54/4526352e8b6a849f661f55f025615eaa0b90c6eccd7588a5eff344c498ae/pydantic_core-2.50.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:dfcf6e51000b34f7026322cd9ea9bc340d745637e27e0bbc22f504b4f6b967ee", size = 2282013, upload-time = "2026-11-05T09:11:30.463Z" }, + { url = "https://files.pythonhosted.org/packages/64/df/6d70c52df400d5af3ccab490a3a3810f6419d99a7a34552dfb2c544de29d/pydantic_core-2.50.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bd46bcc28ed85d3cf71581b42d90b4ebd1f2062f81879519fed209f8a4d1cfc6", size = 2322022, upload-time = "2026-11-05T09:11:32.344Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/4ea1184eb9fe6b5f76e18195b10ab73e9d23f896144773d21bf6938442a8/pydantic_core-2.50.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:696613a0c0e7b2b35356e389ba4195ce20cfe7a2fc2e992c87d974d973eabec7", size = 2493416, upload-time = "2026-11-05T09:11:34.204Z" }, + { url = "https://files.pythonhosted.org/packages/6f/dd/918b33fd36c3a27139fb08cfb778b2392ab9e3f091948b4e4b2a325b0913/pydantic_core-2.50.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2980a701be88f9128a7470189fa3bcda5387f76c298a6f8e000fc29eb2a6f4e7", size = 2499741, upload-time = "2026-11-05T09:11:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9f/dcde466c089e54cb0067a469f54cc8d00a51dd36c6c75eb664a2f1ed07f4/pydantic_core-2.50.4-cp310-cp310-win32.whl", hash = "sha256:dd8bed00731a21e3106e5dd6bbffb6c775a2c0bbc7c0c9face534d830057bdc9", size = 2134504, upload-time = "2026-11-05T09:11:37.693Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d4/d1da421fe867903d5ddffb4d4b4928fd08af99de3e6ec21e758f9990337b/pydantic_core-2.50.4-cp310-cp310-win_amd64.whl", hash = "sha256:e512c75ec5cf4475c1aa50ad975ecdf253f4963b06e152e1a11edd6de4010753", size = 2259729, upload-time = "2026-11-05T09:11:39.567Z" }, + { url = "https://files.pythonhosted.org/packages/df/00/85e80811834d7cbd9bc228f9b5cdf8ec0edd69b55351ad720aac8c412d8c/pydantic_core-2.50.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3e547ceec1da7382f01f9e13963023e53367a71802c88778b21b9645a077cbdf", size = 2170584, upload-time = "2026-11-05T09:11:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/a3/f1/438de1fb99d717dcc6521922478dc16ef7b757302d4fc1f7faba3aaeac09/pydantic_core-2.50.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c813ada7a651193acda85b6bc1cf12abd4b37cd25c7c6a75d7183512cc374969", size = 2128471, upload-time = "2026-11-05T09:11:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/b7/74/f21ab568c6d3f315988b40e53f809c95b63fafc5403332963aa6dfc2c054/pydantic_core-2.50.4-cp311-cp311-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:57fa0a9490785139f1a4e4f43891830d55dfe4408845c0c9cb5eff4aeaf75f69", size = 2281509, upload-time = "2026-11-05T09:11:44.359Z" }, + { url = "https://files.pythonhosted.org/packages/a1/42/24ed3ea8e2fb6463c9ba5b8b1f089e69040905c7fd81dcfa6f52fcc692d5/pydantic_core-2.50.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:30d4dc35bc0eea52202a90b3820445ee0072c820241bfa710a623a078c9b267b", size = 2128064, upload-time = "2026-11-05T09:11:46.01Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d1/44f541b2c18a46df6f18281a557acefeb5bbf0733a0b80b0a5e835e10ee4/pydantic_core-2.50.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:789f88dea57841cdebef392b5d6e90444d767117e9ff2e0d9172df5c9de6690c", size = 2214472, upload-time = "2026-11-05T09:11:47.503Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1b/e9a9ad8733e8051abca9e4acce37ba3ddd13930fd3b8730f4c7816fa979a/pydantic_core-2.50.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d690121e80ab16e623415351ab24713534d4e500b3cadff216409cb6826063d4", size = 2401153, upload-time = "2026-11-05T09:11:49.403Z" }, + { url = "https://files.pythonhosted.org/packages/79/84/d88e8e6aa42d4d677666d6625db9177ad6851be9e7564a25aeb0b0cbcaf2/pydantic_core-2.50.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b42f64825f5d750228f08318a9bf4d723b7ecb4c472aedcb3013c3ec2dc1f949", size = 2453307, upload-time = "2026-11-05T09:11:51.118Z" }, + { url = "https://files.pythonhosted.org/packages/56/51/cbb7a1bc0196ff94d3478d265eed7449f811759988eb760d41f4edd0893b/pydantic_core-2.50.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0574866e05f6adeecc520d87e87dd06210c0a51706341f6ef8421572f5a64444", size = 2253434, upload-time = "2026-11-05T09:11:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/9e/04/206b4881d631033ae88f005da6e11bd610c850e1850217342c03053d253b/pydantic_core-2.50.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:dcf2c7eaaa4cafe8e49b017e5bb49e76e084ab6f0cd712ee4019c812b5cf68b8", size = 2279365, upload-time = "2026-11-05T09:11:54.822Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7c/d815b5a3f83069b283d3b6a2e61de6e14baac3b20c6cd3418e9e629551cf/pydantic_core-2.50.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3b8ee2589a082739c522c1fe4be227412fcf108b903abb6672ae2f5570558882", size = 2319407, upload-time = "2026-11-05T09:11:56.46Z" }, + { url = "https://files.pythonhosted.org/packages/ee/00/c9a483276d1052d5142e1e01eccac97d939f83a3a3fd94c4766495a9b807/pydantic_core-2.50.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:aec3c2d741a8e3ee993de7f95f37afb51e31b169c95dbd29da4cf6688f340914", size = 2489900, upload-time = "2026-11-05T09:11:58.102Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b5/8cd8ee29a1d63ad47f95593384df92476cef6896c644f280db0748b8e76b/pydantic_core-2.50.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8923fcd49f875e8a0448d7223d1fdf1a9973ae2d1f20d4158753ceb8909facd2", size = 2497732, upload-time = "2026-11-05T09:11:59.928Z" }, + { url = "https://files.pythonhosted.org/packages/60/31/050a48c0c373df16442ac07f3fa167414bb0e22f54a3e886f6d6cb7ad1d4/pydantic_core-2.50.4-cp311-cp311-win32.whl", hash = "sha256:fc04affef30be98c1f36f0c422a4061ef618fdc1dde79f5ad5458dd496fecffa", size = 2132347, upload-time = "2026-11-05T09:12:01.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/06/cebeeb7a8e87b42a3d8bc189d0d400e47df5d1736cb326764bae8a74c433/pydantic_core-2.50.4-cp311-cp311-win_amd64.whl", hash = "sha256:15bece43a02ad543c621c8305e67dedb5f27d378c5ba741e5a2c36523f8ef37f", size = 2255970, upload-time = "2026-11-05T09:12:03.888Z" }, + { url = "https://files.pythonhosted.org/packages/46/db/5b4bf14347da658c994483043fbabffedcab98929c41c261ec168c3c1f87/pydantic_core-2.50.4-cp311-cp311-win_arm64.whl", hash = "sha256:e498900bfbf7ddddd862f810440c09e9b81674739e8a11d619b40906dccd4a9a", size = 2196799, upload-time = "2026-11-05T09:12:05.389Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/d1f6b81709538f831ebe1c42f50fb89db3dfda06f9e9b1ea24e90fc69209/pydantic_core-2.50.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:32a991aebd165f227b7c7c60bb9d66c2c7de9c24308552da264c9938fde0f8d6", size = 2231444, upload-time = "2026-11-05T09:12:06.969Z" }, + { url = "https://files.pythonhosted.org/packages/c1/39/6618e541658d5a687a8d4962fc304350073a8f322bc4c62e8ed4c2d514d6/pydantic_core-2.50.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e20cbde2b6deb33a51d08cd168454dd9044ec7d4474056f4d98d63f51578783", size = 2108048, upload-time = "2026-11-05T09:12:08.85Z" }, + { url = "https://files.pythonhosted.org/packages/d9/54/1c941fffa4e14bfc9ab0cd4977075f0e03201447856130d08a33cd45e47b/pydantic_core-2.50.4-cp312-cp312-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:8cceae48ae7f94990c2f4e36291e9ec9868735e8b0f19e4489a17b0422338466", size = 2256866, upload-time = "2026-11-05T09:12:10.495Z" }, + { url = "https://files.pythonhosted.org/packages/61/a0/8028ef3e014be522ce43060aeac82a5be2288215e660866f5e6260acc123/pydantic_core-2.50.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:16902a147b3da4840612164a5e67947104d63e978f7a57c395877a5e26690b9c", size = 2125682, upload-time = "2026-11-05T09:12:12.278Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f2/b98bb73b1734ec4e42e786a766abc24f3c89620a0ddbaf8aa14e4b4c628c/pydantic_core-2.50.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:d6e3485a5e5c01fb5339c1ee3d4b9e21e2135a2a9d44caafb71acd994487ee7c", size = 2197108, upload-time = "2026-11-05T09:12:13.873Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d1/7a7142786781400143e8806633fe5b65e2d407c311041fc9494490f05b18/pydantic_core-2.50.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:629eae995185540406e3635df5d5cf1d44f19ea240840b835d3f44541ff694d8", size = 2380521, upload-time = "2026-11-05T09:12:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/b5/34/cf710ca48d4498b04a3ea605d45892675dc2b14a94a25121a8d03f56d3e9/pydantic_core-2.50.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:41b2cd8b29dbb5a4c9828a70f50d1abfc92011115c813213d3a91205c6f731b3", size = 2457436, upload-time = "2026-11-05T09:12:17.447Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/e0c6e3cd09a0ac809953fea698c816b9f03bf0b35b6820c9553e4f5d85a8/pydantic_core-2.50.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c81d98e27d9d9261909dcccab1ba5d6ea1c98b0bb9470e368604614fd1d36ddd", size = 2261727, upload-time = "2026-11-05T09:12:19.097Z" }, + { url = "https://files.pythonhosted.org/packages/f9/00/292a0ed96d5d2d6edb49468ef481336e5b7e54f13435d73b7c912c891c23/pydantic_core-2.50.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ca83a32b2b41653d89f09a3d22b770bca96da72273bef8f7a68136bdd1e8b823", size = 2262071, upload-time = "2026-11-05T09:12:20.78Z" }, + { url = "https://files.pythonhosted.org/packages/f8/36/d4690faf8c082eacf7754e52ab6bebe1bc73b167272e28699c00480ee908/pydantic_core-2.50.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:817953730e4a73a09008b010c4a42396cefe799616a22df2ac21d3927b3080cf", size = 2298931, upload-time = "2026-11-05T09:12:22.614Z" }, + { url = "https://files.pythonhosted.org/packages/21/17/b180af6d17cf6c8e0d3d2661fc501a4a40959744ca4947d6ad0db81b1416/pydantic_core-2.50.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:664e74848c2f5fc27db8505fe24e1ecd3ac669412913bf73d176916f86cc08e2", size = 2474285, upload-time = "2026-11-05T09:12:24.842Z" }, + { url = "https://files.pythonhosted.org/packages/6e/bb/7e44d4ac6e1292f1859384c688713045bdf5448e0635c49c003d2be3bb6c/pydantic_core-2.50.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:219114f8dcdcbe4720d82ef016b1287ce5a0a67d7c69ca61524eb72e82aac344", size = 2498172, upload-time = "2026-11-05T09:12:26.434Z" }, + { url = "https://files.pythonhosted.org/packages/d8/84/5101f35294579c4bef7dc32fd4f097d91eedac2e195894266bdabc1e6dfd/pydantic_core-2.50.4-cp312-cp312-win32.whl", hash = "sha256:67f7cc397e1d5556a801d1cba74de0d27d827eb242e6c036c0ca273c745414f1", size = 2119592, upload-time = "2026-11-05T09:12:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/82/88/d438a8fcf3738e9ff2670f724047ca3893ec899bcd5cdfc077a2aab133c6/pydantic_core-2.50.4-cp312-cp312-win_amd64.whl", hash = "sha256:ea2f61b0bc12f3592288503961b764579dea0658852d96a0b3d823614d808821", size = 2264064, upload-time = "2026-11-05T09:12:29.538Z" }, + { url = "https://files.pythonhosted.org/packages/77/d4/4a0c73dc3ef9e50b122b8c3527afcded15a44838f1c9668d2d4b72cc09f0/pydantic_core-2.50.4-cp312-cp312-win_arm64.whl", hash = "sha256:32d6dda86383645ec428ea64dfc277036f5cbc60a72238eab28b91a3fdb9a113", size = 2167792, upload-time = "2026-11-05T09:12:31.103Z" }, + { url = "https://files.pythonhosted.org/packages/1b/21/39f8d97e7ea8e3083606ee9bb1d7f3ec70ded780bb6a872aad76c7eb08b2/pydantic_core-2.50.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0f87ae9db5ba8d8de40dcb93982cf8dd544e6552a80d3345af51941cc2146b80", size = 2232841, upload-time = "2026-11-05T09:12:32.617Z" }, + { url = "https://files.pythonhosted.org/packages/90/a9/fc89a632331882c4cb91b949003418e6f5085ddcf2c9b875d499682bca91/pydantic_core-2.50.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1239daaf063e62213ca1f34d96663900bd7510c42a786e843fd328af70e46445", size = 2108326, upload-time = "2026-11-05T09:12:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6d/4f6d00e9e4ba2a7bfb206ae49cb32a3ea90c4d658e62f8775277d75e8291/pydantic_core-2.50.4-cp313-cp313-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:1afefe44ec042fbabfb4483ea258c04cf75ff4ff328d754f09b321fdaa232aca", size = 2256276, upload-time = "2026-11-05T09:12:36.212Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b8/936cb0d85a4082e902c7397db0f05c4372eac08372c8bd2d0b43088fcf17/pydantic_core-2.50.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a9ee990bf2cfbd81574c5d4f9f6c9d480a14b5b43d2c4f2ca13f4c6f48089394", size = 2125469, upload-time = "2026-11-05T09:12:37.913Z" }, + { url = "https://files.pythonhosted.org/packages/66/a1/f3b551ce233dcac73286b8b2ae45c23232a9f41c68663e2dcce4bb2dbcfa/pydantic_core-2.50.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:ab2822bcdcefb54d1e8ca29f5e3ec3afe160fb794bb3a3a73b09d11ed2671104", size = 2196397, upload-time = "2026-11-05T09:12:39.725Z" }, + { url = "https://files.pythonhosted.org/packages/3d/93/a8a6e7e20826567a73e6f52416667698aa3de704804d7a49d25272246af4/pydantic_core-2.50.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0001d6920e3a5d92758cda460d6dc9f918a78ad6d5d092f1c8b5395e6b775356", size = 2380442, upload-time = "2026-11-05T09:12:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ba/921d90c9d8ddfa653a35a92f2bbdcd650a9493f5c43f39cdfe4c56659cfa/pydantic_core-2.50.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ad92d255a165cde5f9882ff69699eaf5495e0a1f32e97566bbbb80f11e46781f", size = 2457747, upload-time = "2026-11-05T09:12:42.723Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/1c8f2c8f1b03d3fe2fdd7962df8f5a744fe48c90b4c49615e4eb77d04141/pydantic_core-2.50.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fb2bb5b557080da398b866408117d98fe9731a12334afd0cdff221b458cdc5dc", size = 2260946, upload-time = "2026-11-05T09:12:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/5b78a78f0c0f11c8b54fac214ed49cbb4795c34881aa5e5a15715917936f/pydantic_core-2.50.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:92f8f63df729061bb14558ed740e7a222f6e2aa6c2e6a5bcfb4e706676562049", size = 2261921, upload-time = "2026-11-05T09:12:45.706Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e8/dd5d154cf3401f836d8754fdc1b82ecadd37944e4355d855e5b54c7d8ae5/pydantic_core-2.50.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2a60ec2a57ac3ef3767e526e9bf706ec75aa3d79c4ea134d9d4fcf7c62a297a7", size = 2298475, upload-time = "2026-11-05T09:12:47.183Z" }, + { url = "https://files.pythonhosted.org/packages/b7/49/af059d9c4483cba168b17e5f84f1090a3e4547a02fc7698ebc231d48298a/pydantic_core-2.50.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a2f8716344bbbd10443391be0b1f2003b34949e3c4e6883005c2bc498ab6b64b", size = 2473226, upload-time = "2026-11-05T09:12:48.785Z" }, + { url = "https://files.pythonhosted.org/packages/71/ba/4e8cc3064e8bc2ea272cf80dfc57fa4e455f6835c1def21609a9b4f94135/pydantic_core-2.50.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:b300a30304c4c6aeab03ed06809819d9eceacdd58441d3c7dffe41edfd006091", size = 2498764, upload-time = "2026-11-05T09:12:50.316Z" }, + { url = "https://files.pythonhosted.org/packages/bf/96/0878499cfa88eb0b07762b488d3cc1b64cb156a4905db99a0d12096b2e17/pydantic_core-2.50.4-cp313-cp313-win32.whl", hash = "sha256:ef8a9ddb2e2a4eae4abb72dc0710eca6c2726779b0664bd10c3ce9fcfaed8c4e", size = 2119158, upload-time = "2026-11-05T09:12:51.834Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e0/95b651213e79ffe0cbee855590bf5cfdf8e3a0d90a7fc73a080e63f7cc38/pydantic_core-2.50.4-cp313-cp313-win_amd64.whl", hash = "sha256:4c29b8f2d6417069bb1f257e6b9113f5d64dc2f554cc200ea90b4fe2c5cdb733", size = 2262887, upload-time = "2026-11-05T09:12:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/08/d3/c3801dc75844a0b13d000bd575bf1b3b3a8aca9f63d19e1809f69c9c5121/pydantic_core-2.50.4-cp313-cp313-win_arm64.whl", hash = "sha256:a0c5210d038418a115e7cf8706bfa611bd14add2f5787274dba45aadca3155e0", size = 2167878, upload-time = "2026-11-05T09:12:54.677Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ed/c9a4982ac3bbc78f27498d9efb85b3dabd739a1724db53c35cc4c018e1de/pydantic_core-2.50.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:5c074454dc6d4f6f4ba6561a56bf33a14db7651d882a5a5321ba3ae8868e082d", size = 2236841, upload-time = "2026-11-05T09:12:56.111Z" }, + { url = "https://files.pythonhosted.org/packages/56/32/92c174c17bfc5fc52d0c064e6a7969a727731e25829ede752fba8bacce0f/pydantic_core-2.50.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4bb174bd65f795c39ad6642d9d5bdc43351b306b7d0a654ab0d0abb38be245f", size = 2108348, upload-time = "2026-11-05T09:12:57.638Z" }, + { url = "https://files.pythonhosted.org/packages/cb/cb/9f637d424a04d3edce7dd527da941950cce05a74b025eef3d54427b86c8e/pydantic_core-2.50.4-cp314-cp314-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:0ec20aca2b067369913716847f7dfe5f4dddf16a46965b7a70bbad5defaaba86", size = 2261331, upload-time = "2026-11-05T09:12:59.154Z" }, + { url = "https://files.pythonhosted.org/packages/91/ff/1117a3cccabb67305bd98d992a84eaf275939a9a043b4f14126ed7252e4f/pydantic_core-2.50.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:70dbc9f83c41f37a4faa62dae5203853847223a891da446f48ef2efcfd5df806", size = 2128667, upload-time = "2026-11-05T09:13:00.614Z" }, + { url = "https://files.pythonhosted.org/packages/93/45/6de1b4a6e8c415cd547f90d1408a711e7975116423e3eb3046061c62d7c7/pydantic_core-2.50.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:3de6c2a4df2af0ac9ab4dc1488746fe8b1a90e27955d870d9edda3187d20092b", size = 2198183, upload-time = "2026-11-05T09:13:02.259Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/c6e64c315d35cc0783a0ea033a3dca062bc368905d0217eab6f6f63007de/pydantic_core-2.50.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3a48a5254d15a0ad050318bfc7a9566d4ec740118187e8f303e47a6840287eb7", size = 2386279, upload-time = "2026-11-05T09:13:03.988Z" }, + { url = "https://files.pythonhosted.org/packages/33/ab/0d56280c89d3841381e9c699c3fa80e3dd3288edf5e7fe4d149136835dca/pydantic_core-2.50.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:0af56dbf8822ba9f4efcbbfdc15478f8b6fb08f1609860cb50aa2cc8476b3c34", size = 2464343, upload-time = "2026-11-05T09:13:05.98Z" }, + { url = "https://files.pythonhosted.org/packages/56/9c/621ad8f88590a92a086b1168dce09369d9c5e079e2e635e6646d248af55b/pydantic_core-2.50.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f1d62fb172db35c6478c9323da34ff37d1f3d37d7d44337a9d94ba8c583e5730", size = 2265388, upload-time = "2026-11-05T09:13:07.808Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7b/cef68a667bf29b86e293a80a601d488a84c8f45508915ad6201fcfe1a798/pydantic_core-2.50.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:5c1013647be741647895c102831dcf79c34209662dbb683df9efb864417ed7d5", size = 2267012, upload-time = "2026-11-05T09:13:09.273Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9f/3ae4d163d8feda2b52de88b5cd96b1c40f79d11503003b061136d2ece6ab/pydantic_core-2.50.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:aeb2e123cbf978a905e9f55155c1b8316fbbfa7cf64d04521fe174bab302d100", size = 2300478, upload-time = "2026-11-05T09:13:10.758Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/41881d31f6452430ed6792679c5391795910f0a27a6aebd424683e504ed6/pydantic_core-2.50.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:523466eba8ee10f7480f3c2057563145978011c583130d2cc01fb1e7af808e25", size = 2474874, upload-time = "2026-11-05T09:13:12.188Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/099874dd6d5ca1e49987dea87eecc05610183249450b1fff883d87f1e8eb/pydantic_core-2.50.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:c9e70012926e97069cdd09221861aff412aa94e4cac2e019055cdb79485b150c", size = 2502771, upload-time = "2026-11-05T09:13:13.981Z" }, + { url = "https://files.pythonhosted.org/packages/c6/07/a7d8615b8677bf69e24666bbce431299850c95a7b084649342582a1c3210/pydantic_core-2.50.4-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:e6da138a1ecc1341c8b788a5ee822670b03788e1f5decf7af9d461de6e508b8e", size = 1496509, upload-time = "2026-11-05T09:13:15.704Z" }, + { url = "https://files.pythonhosted.org/packages/ab/71/fdf2c25c3a4f89968263c1d896c933809ca8d2fb13443626648a17b2e296/pydantic_core-2.50.4-cp314-cp314-win32.whl", hash = "sha256:13b6dd62b5279f5b9d72f3bf918a35443ee8bd549241d9dc52d79a2c3b6d7016", size = 2122015, upload-time = "2026-11-05T09:13:17.09Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9f/3554f0148b01ddaa5e678bf5fbce4679873534e5c0985cdf6c59c41dc9a9/pydantic_core-2.50.4-cp314-cp314-win_amd64.whl", hash = "sha256:b0cdb655fbd1562c2dfc9986f9103a106f7058cd0915afc6e656d4b1d73f3387", size = 2265109, upload-time = "2026-11-05T09:13:18.702Z" }, + { url = "https://files.pythonhosted.org/packages/ed/3c/196ef237d9ec5cca5ea9b773911dee1a387b9b1d4bfea05625b3e191a338/pydantic_core-2.50.4-cp314-cp314-win_arm64.whl", hash = "sha256:df1c174796cd63eb6b139f7daf783affe1f3ed659605850e843b24764c21bfc5", size = 2165745, upload-time = "2026-11-05T09:13:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/36/12/0ee82a41a3995f0b531cf14e0b823dc2365ba4a22330670fccaa72753f12/pydantic_core-2.50.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:edbca8789fd10f0de534b660a6b4c71f1fd18039679c918dad392ab044bb37e4", size = 2223917, upload-time = "2026-11-05T09:13:21.623Z" }, + { url = "https://files.pythonhosted.org/packages/90/2c/592f8404abbcc65a70e6a802d1dc8f11412af44913e5146cf5c345370074/pydantic_core-2.50.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:33b21f69540a12c2053cd1d95766d8bd9667a3897f7c712a642de8aa37eb0865", size = 2087372, upload-time = "2026-11-05T09:13:23.16Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/c7111757c84b5b8c19b1e39ceb5e131e6e48e14169ec6004e0f8f28f43f3/pydantic_core-2.50.4-cp314-cp314t-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:ffb616d8d55965839670301dc3bae3abacff560a7e137a89056058a8a9da4d96", size = 2275190, upload-time = "2026-11-05T09:13:24.634Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ae/6c49cc9b8e66e61adee782d1763894bf64a357cddae82f725059b85bdc98/pydantic_core-2.50.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c5687b59b6ba0f663c4f2f15470f68507d6b45ccd3a19ee6d224f95cbee70d8", size = 2110343, upload-time = "2026-11-05T09:13:26.168Z" }, + { url = "https://files.pythonhosted.org/packages/ed/39/0ee5261d1daa9ad526371181c13696add0565251b2823f6e00a9a9af397d/pydantic_core-2.50.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:c733087ecf7dde06b1d071679bfd5f3ba03266ba77814c1c7253bc48c9adedd9", size = 2207887, upload-time = "2026-11-05T09:13:27.681Z" }, + { url = "https://files.pythonhosted.org/packages/ac/b5/a0652c0e99590107e111088b6d059044380a787da9ffdd0b458ab4cd9453/pydantic_core-2.50.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94733cb447c37b0ce6f71b39ca1288d5f966a084023a5617b4cc35a852586859", size = 2404171, upload-time = "2026-11-05T09:13:29.864Z" }, + { url = "https://files.pythonhosted.org/packages/a3/85/d49f0b8e09a0ffc40dcefb604f9e6020e0d61b51a5f64a504f382fa84c39/pydantic_core-2.50.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b26f39a5be3cd91a2035818be3d0ffdf5282c3e1f5f53d3b80b7024021acee52", size = 2466049, upload-time = "2026-11-05T09:13:31.658Z" }, + { url = "https://files.pythonhosted.org/packages/e6/76/f2b04d7c53edfd8136053c812ea49914fbf4091196cb7baf9ea326fa2ff8/pydantic_core-2.50.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c856e439a9d54d514e81a7131229ba151cdf81df7c408e0f6c89340e5003dfe2", size = 2262062, upload-time = "2026-11-05T09:13:33.806Z" }, + { url = "https://files.pythonhosted.org/packages/24/60/016f14cea883adc33df90b8df220df934de49ded9640f45c8e92bb298494/pydantic_core-2.50.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:cbf0777d54672990b10cb8ca9b0523499e182d7e2ea39f86b138519a902aa601", size = 2263713, upload-time = "2026-11-05T09:13:35.337Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/b05828e978512973e7e215bab69e65e70d223d6309a17bf48084a50692f4/pydantic_core-2.50.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:edc17549ab592e33cfcaf285f047bb75fcd98f608aa9309af1187fd736ce9641", size = 2305044, upload-time = "2026-11-05T09:13:37.043Z" }, + { url = "https://files.pythonhosted.org/packages/91/c1/fca6c5f623ac45ba284713c614b30173aa0794899a0a457f866fa6f5e886/pydantic_core-2.50.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:23a9ba564b2db709ff16eca990992ba95a19069972b34bff87c81d4b473cd755", size = 2483506, upload-time = "2026-11-05T09:13:40.524Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/ec41f68d1208e53e1a73ead940c050c4316bbb555ba42676a5e949fd529e/pydantic_core-2.50.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:41f4f8ae714e4fea1d635880f8a1af715ecf080eed4d13c5b04a0f3210f9c61a", size = 2512832, upload-time = "2026-11-05T09:13:43.361Z" }, + { url = "https://files.pythonhosted.org/packages/43/22/58583b86309f70cce854e0e77c412839a56ac6c682d6060f5794afeff315/pydantic_core-2.50.4-cp314-cp314t-win32.whl", hash = "sha256:601fce6cea9ddeaa85329e12e7f351def38d233075f42a3012b9c48071b02941", size = 2101977, upload-time = "2026-11-05T09:13:45.735Z" }, + { url = "https://files.pythonhosted.org/packages/6b/28/193bc0994f7fe15f2874c1dfe2f3bc7872aed8283ffda2cf05fb9a5a06b4/pydantic_core-2.50.4-cp314-cp314t-win_amd64.whl", hash = "sha256:22e874666bdbd96d6bf629d32cd8ab376fedf1fd62585b52a42b9fa9fa6c20b6", size = 2240461, upload-time = "2026-11-05T09:13:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/00/ed/5615cfdd5620a4926440eb1a2b73db0dc895824ba565ccaa8d9eb3a8e974/pydantic_core-2.50.4-cp314-cp314t-win_arm64.whl", hash = "sha256:71395a43da5584bb70556f380387e15f845c0dcf76373c1afbfa0025737576ac", size = 2168505, upload-time = "2026-11-05T09:13:50.762Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9f/e6673535165d965a11c1d5339f53995f90234019827eaecb41a57e6e463d/pydantic_core-2.50.4-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:7ac2769a7ddd661e07e26c12220adc31b17014c48234b2a5a88993b8d4109e09", size = 2241950, upload-time = "2026-11-05T09:13:53.055Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/65951efadbf7826e6a270843f97e3da2ef726de2b6d9cd7c5dfa4c48807e/pydantic_core-2.50.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:930ad463d3fb7a340d323bcc496ec32dae469ec9974c29ff569f19806b6b03b4", size = 2112412, upload-time = "2026-11-05T09:13:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d3/4fb6e2e4a9e4951f1e05b198366ed5330e0874e83d52077e8b985350fe0e/pydantic_core-2.50.4-cp315-cp315-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:b867f9fd183d76977f810c626d539265183c25b2284762893a31e8d6cacc81c0", size = 2263220, upload-time = "2026-11-05T09:13:57.555Z" }, + { url = "https://files.pythonhosted.org/packages/63/83/42e70436c523357b49393b73f161f2741acf36003c8efd2f941d2be56bae/pydantic_core-2.50.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49eb1241a969a5994db6880c18c012b6cbe2340343d8f529721571806583e500", size = 2134716, upload-time = "2026-11-05T09:13:59.413Z" }, + { url = "https://files.pythonhosted.org/packages/36/66/ed9b87f4751fd07e472c62cfe151f575abdf4c452fab943d35ba8efdc6c4/pydantic_core-2.50.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:477af785d1846e4e96bf56983b92ea7553aaeccdeb8acdaa55c7cee9eda0ae32", size = 2201901, upload-time = "2026-11-05T09:14:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/2b/21/ff2e6ac7202b6d326e9f007c6082f6e6f3aaa063790ea2a355bdc985addc/pydantic_core-2.50.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:29358a8ca74e7d81158d14e2b10c904bd18773293ae3788716c0626fe0342347", size = 2389670, upload-time = "2026-11-05T09:14:03.431Z" }, + { url = "https://files.pythonhosted.org/packages/b9/34/e9348bc13d549641791a51843ed140fbb311d9066628e27477343412d5ad/pydantic_core-2.50.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:300753f71c73302e218124baf995f00058e085cd01869bd9610bcbd0bd4500c1", size = 2468951, upload-time = "2026-11-05T09:14:05.028Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5e/145a02bf2b9f66981108caf6cc9e78917e31e4caca14d1e3ccd3ff2c7293/pydantic_core-2.50.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:26cb012c4b8ca9f847bd0ac15978bd3d0f55b050bfbe6c47355edfc7e945029b", size = 2272957, upload-time = "2026-11-05T09:14:06.587Z" }, + { url = "https://files.pythonhosted.org/packages/fd/38/b71c5b245e923f6cf144e948ed2a9d6b7fb97d8905784db8d860f5176b8b/pydantic_core-2.50.4-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:9be1cfe715ecaa060ffd5d599ef410b94ef2697a0eed4cee562f788a2aa2175b", size = 2275028, upload-time = "2026-11-05T09:14:08.219Z" }, + { url = "https://files.pythonhosted.org/packages/ba/71/2504dacdc5cd3ea3ec5e68c9b6e097c979500bcff220b97046cc8ea3c1ad/pydantic_core-2.50.4-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:c81e35862aced259177a537ce1a6264c3a97bae1ced184ca9dc10ea754563688", size = 2305447, upload-time = "2026-11-05T09:14:09.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/90/904233a6c148bcac9f30bb96a2761e5fb52682d0d6de31a40c74a3714ceb/pydantic_core-2.50.4-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:b2f2d13239487cecaae98c9f4d5904ad0737643cdf8873c88ec6a78b12214f20", size = 2477852, upload-time = "2026-11-05T09:14:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/72/33/1cbdcbf343ca23b507e967b21dddc4c8bf17a74c8b5cad59228c85ba4efc/pydantic_core-2.50.4-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:e6b4b08d09054310a4dc389fa61ee22a7a5a292cb23ab8a985136ca8828bf924", size = 2506352, upload-time = "2026-11-05T09:14:13.165Z" }, + { url = "https://files.pythonhosted.org/packages/91/24/3ad9ea0876e6624c90967e5f1446e751b275cfdfaaf798a0d8e0b30025bc/pydantic_core-2.50.4-cp315-cp315-win32.whl", hash = "sha256:6619d49a89a15cd04334f76fec530c155923bfc60da74ff5d815a52629e1aa6c", size = 2127780, upload-time = "2026-11-05T09:14:17.52Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3c/0582532b90a12492471977bdbca1317eca5144f6342ea581d2cee73ccd35/pydantic_core-2.50.4-cp315-cp315-win_amd64.whl", hash = "sha256:e5bd109d095aaae989a0463d5ef0fcc43e9ddd5de125e656d3c119a79e466380", size = 2270596, upload-time = "2026-11-05T09:14:20.955Z" }, + { url = "https://files.pythonhosted.org/packages/6c/50/967cfe4f7043a993c0c80cd3563c598b5281d2276aa775d0f6ff7f714271/pydantic_core-2.50.4-cp315-cp315-win_arm64.whl", hash = "sha256:6118e5a5550e36582e19bac1a403711a748f9f18251b054c326d3db02aaa7a18", size = 2170880, upload-time = "2026-11-05T09:14:24.822Z" }, + { url = "https://files.pythonhosted.org/packages/9e/71/3ec5bd8a82f792482f16d79fdbe8ce85aa71bf7147e556c5fc5f8546818d/pydantic_core-2.50.4-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:d8201c9f9035520cdcc7dcad383b039ec8123969b87e01cf27c3dd883c02313b", size = 2233053, upload-time = "2026-11-05T09:14:27.942Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2e/614dc37aedf8e9f811c1f2eedaf8eb09a7c196e2d137fe82bcc9d5662ca2/pydantic_core-2.50.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1de7be9a66fc8536770481ab64a05aae5067c725f2b29a09543af00e6a417b97", size = 2090776, upload-time = "2026-11-05T09:14:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/f9/14/aefc14e3e05013070ee959fc0b8703f6860a2776ed6e75b9c748f61aced0/pydantic_core-2.50.4-cp315-cp315t-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:789a6453e5ffd18a7ab6b8c3a0dd8403053829197ec258ecb90c604c21ffcc99", size = 2272481, upload-time = "2026-11-05T09:14:32.578Z" }, + { url = "https://files.pythonhosted.org/packages/13/c9/d56999aa7236dc9303091db2b1ef7303f0b022baf25ebf33cd200c0cb933/pydantic_core-2.50.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6348ad12fa741bcc9c2f173e99fbf27cbc038826d8c31a4a7d836eca27878ba2", size = 2113069, upload-time = "2026-11-05T09:14:34.69Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d5/a62da9e72f272f198f6118c5457332bbd4b9fdc4b77858448120569756de/pydantic_core-2.50.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:d49127f137f871cde3c5650af33bbbdcee02679dae76e020902fa106f522b8ae", size = 2202754, upload-time = "2026-11-05T09:14:36.49Z" }, + { url = "https://files.pythonhosted.org/packages/37/4a/e7f21577f96e42ac3082cd014df398c1c913d482afac1c8ef17b9e039198/pydantic_core-2.50.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:2f5cc5bcb94063fbbdfd285a2b302da34af07491337c10bb35de1d5220fa1583", size = 2411472, upload-time = "2026-11-05T09:14:38.63Z" }, + { url = "https://files.pythonhosted.org/packages/38/ad/c57c5e13bbfd3404ec1af519764aadbc52ee01e39f9f9ae9d28317f982f2/pydantic_core-2.50.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b99bed995ed7c41515c11fe2beb86f8a6cc298e5537cae2f736de2f982f74ef0", size = 2475330, upload-time = "2026-11-05T09:14:40.519Z" }, + { url = "https://files.pythonhosted.org/packages/6e/63/65cf0e1f1fd8cfaafee3c005712b8020e063b0f3e3571493ea68db5ec44f/pydantic_core-2.50.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:faa4e96385c820ce9d7f7a623486e23d4134ae3c9ba2071e44e0ab5a5a5002c4", size = 2265718, upload-time = "2026-11-05T09:14:42.379Z" }, + { url = "https://files.pythonhosted.org/packages/72/5d/fd9fdec9b25e2ef1f788970a84f749f0e28f166573c87654fd5387d8e5ce/pydantic_core-2.50.4-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:d6a166978f22f10cb91413f70519861605219346decf65bc79008f9257c94afe", size = 2273454, upload-time = "2026-11-05T09:14:44.286Z" }, + { url = "https://files.pythonhosted.org/packages/34/8c/19705c6d5eb72ff8c25006df8a375da7466176a5968781789e5415384f65/pydantic_core-2.50.4-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:a70d5effebd81ebd8e15a8aabdc7980832367b2b00c36543bac378977004cc80", size = 2314862, upload-time = "2026-11-05T09:14:45.97Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fb/acefa27031aba2646b0d3026c4a1602e526489d39395ab17bc47f03234e3/pydantic_core-2.50.4-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:f8fd23653a252cefabea11cd041efd7b80bdf403e43a36dea53f9cb0aaa21f0f", size = 2479073, upload-time = "2026-11-05T09:14:47.526Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e1/db08fa1e5edf1779fa0612f8d09f9030bdc513f593687c883cb781b86caa/pydantic_core-2.50.4-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:7646353a5a7c553c04167aca628e2550b538f492765b03ccf34321a560acc43c", size = 2521328, upload-time = "2026-11-05T09:14:49.042Z" }, + { url = "https://files.pythonhosted.org/packages/0d/51/bf1ec5b96c076d8d514b08f3bcffb3397df7a0fcc729eb960838a8bbe27f/pydantic_core-2.50.4-cp315-cp315t-win32.whl", hash = "sha256:65376dbe1ab4dbc768ca2b72b7c13993562c035f5925fd3bc7ff9c8c585c515d", size = 2107629, upload-time = "2026-11-05T09:14:50.891Z" }, + { url = "https://files.pythonhosted.org/packages/18/32/43d5db1824ec2314f737ff8d9962ff59954a74616d8656a8cc78790e45f1/pydantic_core-2.50.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f08ee8248aad2d054e5bc4c73554d4505464698f6d754f16198dbc5764e9821d", size = 2244906, upload-time = "2026-11-05T09:14:53.056Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c0/38c505004969c4641c3a077a8e72e354f02d5c06d8f51d7a103699704772/pydantic_core-2.50.4-cp315-cp315t-win_arm64.whl", hash = "sha256:da779ae50b46f3f7d1e1b5f362fdc0ad376a49fd55f6f3122dc2b6149936cdf7", size = 2179018, upload-time = "2026-11-05T09:14:54.448Z" }, + { url = "https://files.pythonhosted.org/packages/9a/18/2f6a25e9104d5af713d16671711927601da1f0db74e785be67bd590385c9/pydantic_core-2.50.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:c04e9d920e2674f54651fd260a15c5d2843526c73d5242ee4311325ddc9573b3", size = 2212591, upload-time = "2026-11-05T09:14:56.262Z" }, + { url = "https://files.pythonhosted.org/packages/4c/1b/ba8b721af10a7659e7fbbef320ff8eb55462557a72607858af5a9bd22cb1/pydantic_core-2.50.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:5bc1811ef2b4d4ba63e8e452f6e332b6374bd1d2cbbfdf66826c8cb1f71dd10d", size = 2048167, upload-time = "2026-11-05T09:14:57.681Z" }, + { url = "https://files.pythonhosted.org/packages/48/42/3f8638e7d90bdd9a7c7f3e5084723af59e7f43f15a292e242d35624073ce/pydantic_core-2.50.4-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b059a7968b1911be5a2d9a8234827ab0bef9d1e465d4b42da525894143550e8", size = 2119548, upload-time = "2026-11-05T09:14:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/e0/8d/4bfbacaf191f7939893d165e18eb301731fa3366c59a6e57336c4f168fbd/pydantic_core-2.50.4-graalpy312-graalpy250_312_native-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bee28aab5a580eec673f7b68dfa8dece3dd37bd207ef8d22fafe23a8e9837296", size = 2279952, upload-time = "2026-11-05T09:15:00.907Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3a/d405a50e02ea81cd95e93fc97f43d351eeaa1707c28aa6fb58a9f1c1efa9/pydantic_core-2.50.4-graalpy313-graalpy253_313_native-macosx_11_0_arm64.whl", hash = "sha256:71e03d2ea1a221d020cfc5b6cc8bbbedef23a06704c70fcf4d0afb42965b22d1", size = 2049324, upload-time = "2026-11-05T09:15:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3b/03e7992115e17cae4131ecd71aadb8fd5e4a04cc2aead72c3c27e9b453d1/pydantic_core-2.50.4-graalpy313-graalpy253_313_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09450778e3ab58d3f565899fcfbf6c82926e31af29e28e799d1d50941316e0cb", size = 2119802, upload-time = "2026-11-05T09:15:03.901Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d5/b76fc647e473377ca6e693c21549ee3a4a7677e4962c28ae4659337c5114/pydantic_core-2.50.4-graalpy313-graalpy253_313_native-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4eb15be588407148ef5937dc8d9256a71f6b115e6cddadb7aa20b6ee384680dc", size = 2280639, upload-time = "2026-11-05T09:15:05.374Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/3ca5b3b8ce7a540305e6856d013e9c6b9c0fef93258fef6038db0beb233a/pydantic_core-2.50.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:39f489ce31272073e6e146f6781690f9c1ee8c126836e6403361ef91154d89fe", size = 2166960, upload-time = "2026-11-05T09:15:06.902Z" }, + { url = "https://files.pythonhosted.org/packages/a3/60/65d725001bffcde0170f6d37ae8bccce715e5311122eb6127f698115957e/pydantic_core-2.50.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b26e5f1793b96cdc60242bf38da86ff24a27a708aa396214cac9b534bf571c7", size = 2085636, upload-time = "2026-11-05T09:15:08.395Z" }, + { url = "https://files.pythonhosted.org/packages/ac/12/934cf1d44a3fee3c47231d4690dddee19264150c3aa59a3ddf66fa43454d/pydantic_core-2.50.4-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:7210398a1607ad607dc73196e575b3148b00a333cbb602ddbe1b60ba54554f43", size = 2284621, upload-time = "2026-11-05T09:15:09.82Z" }, + { url = "https://files.pythonhosted.org/packages/c4/91/52248eee98d76f8d9239166d24928f805292df16729109f4f189cdb59be4/pydantic_core-2.50.4-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2f42584e5785385f681163a42506cab0d51295e17599a648396c52a7046c1372", size = 2277673, upload-time = "2026-11-05T09:15:11.236Z" }, + { url = "https://files.pythonhosted.org/packages/9f/89/900a7970ef19843aa79522a95b461e9c365b7fedd7ee80ef28a84dae9a41/pydantic_core-2.50.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2039e323acee9abfec1fb5f661776e8965fe5928f3b7f8431900a774cba5c317", size = 2297354, upload-time = "2026-11-05T09:15:12.798Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/053d829437f70070cdd7116a0257cc0da651103328ff1e9e82954c45efd6/pydantic_core-2.50.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:71ade6d2e445839350d589ff99bbddfde16f3f32db28da5c894a09f99c8b2a03", size = 2469913, upload-time = "2026-11-05T09:15:14.319Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/207c821cb0813e16d630660e0073491e3ab07370efaa6fa0b8b10fffb7b6/pydantic_core-2.50.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d7d406709ae924cf0b08b8282ca2f0e1c90526911d93bc040557cbc83b74c55f", size = 2503746, upload-time = "2026-11-05T09:15:15.736Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ca/8a053f24e22d8150349cabaecb2f35c89dcf07fbaadb5e48295184dd0e5b/pydantic_core-2.50.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7472c39480ccc11dda69a196d1b7c7a6b48eb6594b77f05d945599d3e47f731c", size = 2333540, upload-time = "2026-11-05T09:15:17.883Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9d/41b374c62cdb87f84081e2ef2963a115eacc6284a26dbc96bb83568e83ec/pydantic_core-2.50.4-pp311-pypy311_pp80-macosx_10_12_x86_64.whl", hash = "sha256:b67d38e8bb0ade7e6e84575e4bb153fab35b122458077273aeb08fdf12be9d2c", size = 2166962, upload-time = "2026-11-05T09:15:20.276Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/51ef440ee346d4388a2c4a3ac4b271fcc7b1d3ff68be8da2ed2be4d488b0/pydantic_core-2.50.4-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:4eda7e3fdef382b7d4492745988a5baaf6953923a337f8b9dde5f76fa8afde20", size = 2085634, upload-time = "2026-11-05T09:15:21.953Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5f/c9ed62b96beb9050db221d29e81856b6e28f277db601f8b0c3a08f8cf913/pydantic_core-2.50.4-pp311-pypy311_pp80-manylinux_2_28_x86_64.whl", hash = "sha256:474f7e598c7902165662a5d5160d3fce31037890a2975a9871ebf7c05862300b", size = 2277804, upload-time = "2026-11-05T09:15:23.836Z" }, + { url = "https://files.pythonhosted.org/packages/63/bd/b912a5e251ef10ed7be5e543a2bfbeebb0c79bf6302bee786f8dc3bf4341/pydantic_core-2.50.4-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:3e0b395648c6cb871bceb2906907f255dc876ec425f2d5b024e129985cea8519", size = 2333539, upload-time = "2026-11-05T09:15:25.354Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", version = "1.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.57" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] +dependencies = [ + { name = "greenlet", version = "3.2.5", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.10' and platform_machine == 'AMD64') or (python_full_version < '3.10' and platform_machine == 'WIN32') or (python_full_version < '3.10' and platform_machine == 'aarch64') or (python_full_version < '3.10' and platform_machine == 'amd64') or (python_full_version < '3.10' and platform_machine == 'ppc64le') or (python_full_version < '3.10' and platform_machine == 'win32') or (python_full_version < '3.10' and platform_machine == 'x86_64')" }, + { name = "greenlet", version = "3.5.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version == '3.10.*' and platform_machine == 'AMD64') or (python_full_version == '3.10.*' and platform_machine == 'WIN32') or (python_full_version == '3.10.*' and platform_machine == 'aarch64') or (python_full_version == '3.10.*' and platform_machine == 'amd64') or (python_full_version == '3.10.*' and platform_machine == 'ppc64le') or (python_full_version == '3.10.*' and platform_machine == 'win32') or (python_full_version == '3.10.*' and platform_machine == 'x86_64')" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/df/7611a37edc4cfa118c364a7f5dec4889ad0e9f84638a789bbf864c5f7a63/sqlalchemy-2.0.57.tar.gz", hash = "sha256:892e0edd06b8ca2a8a3f9107611047914fd82ccd03735efd706d12aa4da1b773", size = 10018207, upload-time = "2026-11-04T00:58:47.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/43/bbc3c13f1bf3b94e029f40e47a3be25f10fd147a1ef9af1ee49040a1d3a3/sqlalchemy-2.0.57-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b1ead374d6ba8d5cb516f48de33ef1d6dca635a69abe7c7a344ae4bb00e7bd40", size = 2198623, upload-time = "2026-11-04T01:06:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d2/85eb533b41fc041f4ebede5c108b51ce22416ae9440be010f4d11b2b318a/sqlalchemy-2.0.57-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e0bde66dd5384e6cd9842e119197c02280bc3b85697995d2dc2106714ab9742", size = 3368986, upload-time = "2026-11-04T01:11:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/ed/64/6e02adf1d1039bbf00e3465d69ec1d2937825a31add6fa40264878994cd6/sqlalchemy-2.0.57-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5108313e44bc1e08a857d36f7f7f7f5eae23687153c5ad21cb6800ac6cb67510", size = 3366703, upload-time = "2026-11-04T01:15:43.049Z" }, + { url = "https://files.pythonhosted.org/packages/1d/df/c5005699c3e46d30a5213c7798b864c012f67df4add6fd8cc4e71609cfac/sqlalchemy-2.0.57-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b0cf6c9dc732258a18ad3a8bd8af5440553fe98192bf49788021dc1ddcb388d5", size = 3316096, upload-time = "2026-11-04T01:11:35.559Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/02937dc47d8ebaf94f02c4f33bb2837e7f3a33d9d11e514d0c2085e8e0cb/sqlalchemy-2.0.57-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:03d147ff68e787833d60b732238f3440031fbc45d4629edf8bcd073e89a461c3", size = 3344399, upload-time = "2026-11-04T01:15:44.885Z" }, + { url = "https://files.pythonhosted.org/packages/cb/43/f99da9e4d685fd3a31181ba4c99c719b28e4d2c1264bd00911f8c2ab5c9a/sqlalchemy-2.0.57-cp310-cp310-win32.whl", hash = "sha256:fb53faa9f2dedd2560d82712f6ae6533bc4eb3de9c0122d85cd29d752e5cd6ac", size = 2153991, upload-time = "2026-11-04T01:14:46.891Z" }, + { url = "https://files.pythonhosted.org/packages/43/90/dbc16eba5b02b0f41cb580d40c522b8d3acfd3207dd1b3413fd82b81fd2e/sqlalchemy-2.0.57-cp310-cp310-win_amd64.whl", hash = "sha256:88007e8f5f263ca7c8adfef6425037e134ddb6a67a99e07cbd304a208a8a4938", size = 2179891, upload-time = "2026-11-04T01:14:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/bb/09/9eff3ad9142f4a53da76b120d0155607584c48b2dc3e4188ed476859c582/sqlalchemy-2.0.57-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:396b8e25a81fac14f5ff9e2b66baffe228a43f1fcd1916f329fce2ea8643eba1", size = 2197346, upload-time = "2026-11-04T01:05:42.415Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d4/35cf9b73856f986f3c88434b838c1e2c3a038ae5f99ad217ea62f0b6dca7/sqlalchemy-2.0.57-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c688e2ef8121479e0e25972a942dd0c056f3ba59a22175523e35106c2e3fb72d", size = 3440374, upload-time = "2026-11-04T01:57:45.875Z" }, + { url = "https://files.pythonhosted.org/packages/85/15/0c9ca267aadfcf2787aa27dc7c963289540fb86696ea8fb6ad29a22a1e56/sqlalchemy-2.0.57-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21ad7dc7ed00a05bd3d179cb9bba85dc4181b04342052d9ce81d9305bfdbf9c6", size = 3438531, upload-time = "2026-11-04T01:12:43.373Z" }, + { url = "https://files.pythonhosted.org/packages/34/f0/743af6e4e9ac65aeace4ff13703d632d826a8d945441b24d8d9da62c1093/sqlalchemy-2.0.57-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:206cbb334df6038f049a11b05b8cb4a4dc13577a46464e01de080d1c3e4493c4", size = 3392168, upload-time = "2026-11-04T01:57:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/8f/23/d82018b2953893da5d00108f5e0fef48c537355a042528cdd7c785b0b693/sqlalchemy-2.0.57-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4e2275e8601de7d8a79bf97bb4cec32b116b4715ec8585ee8a629d2cf2512176", size = 3415564, upload-time = "2026-11-04T01:12:45.409Z" }, + { url = "https://files.pythonhosted.org/packages/62/55/6dcc3d70761ec89418b5a218b233562987a82892009db20d23ab1ba81805/sqlalchemy-2.0.57-cp311-cp311-win32.whl", hash = "sha256:6fae1e630982481e705dcab2442c8135e5287a2290e23fd51fbb031c67a8b7fd", size = 2152007, upload-time = "2026-11-04T01:32:25.786Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c9/5e1076c69e96e505ff50f1d99454d1567d51d71ded1052039bc97d76d1c9/sqlalchemy-2.0.57-cp311-cp311-win_amd64.whl", hash = "sha256:0e5a8cc2e70f868e579e083818d9bfc7ccc722e588ff22bcfa5cf5da2d571967", size = 2178758, upload-time = "2026-11-04T01:32:27.229Z" }, + { url = "https://files.pythonhosted.org/packages/68/78/27f8b0f5c59006f98875b3166347615a2aaf196ccd9a400d6a95f56e0364/sqlalchemy-2.0.57-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e33889a6f292db9ed2278d5562717c96432651ec558417a7acf17d9549fd6935", size = 2197129, upload-time = "2026-11-04T01:05:44.509Z" }, + { url = "https://files.pythonhosted.org/packages/41/f3/702ae27c52acfaeb6dde5ef1a49a8fa1793e28834fe77be7c3a4cb7abfb5/sqlalchemy-2.0.57-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75135c4c54be953730e41203f38ced685b60dfb16d61a7d4d9a7ab8d87ced6f1", size = 3461341, upload-time = "2026-11-04T01:57:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/df/2b/6722fc370f7cbc75fd6df6ba3d870ab4ee40a02c4875162e2bcfe4a5ad83/sqlalchemy-2.0.57-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ff9f7dda4d136bfd7fab44384ef7e3135c9826da815d6248f713d0035a6274b", size = 3478029, upload-time = "2026-11-04T01:12:47.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/4ff40e4d17c36c6b8d328ddefc66abe0b5f3b4d257a61d430a6dcded4ab5/sqlalchemy-2.0.57-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e8d67fa9935021bdccd02d313a1b5ce92531f679b0e70ea7b9f5d651fab7f742", size = 3412804, upload-time = "2026-11-04T01:57:52.379Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/6bc26429849cc799ae5a5c9c8657491eff777f50a5e38e91626d79984d68/sqlalchemy-2.0.57-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d55ea24654f8d982c5aabd6e6779eac3ed15f420f629761c4eb627399e9d8cec", size = 3444039, upload-time = "2026-11-04T01:12:50.017Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b4/5c67a4c220c84ac9923bea8035e1ad6e0982191ddf56008228ad30edc974/sqlalchemy-2.0.57-cp312-cp312-win32.whl", hash = "sha256:ffaedafb38901bc72efcaf9fdf57bc363be24bd67a8bce8e3afe4ec75bc82833", size = 2150331, upload-time = "2026-11-04T01:32:28.689Z" }, + { url = "https://files.pythonhosted.org/packages/c9/72/406430db968b16104bb4132fc6394d279e1e96b36994200879f76eb8da52/sqlalchemy-2.0.57-cp312-cp312-win_amd64.whl", hash = "sha256:4972e01af81b72b78e335735eddf9d4ef8980eca1e8364552715991a454eea97", size = 2179710, upload-time = "2026-11-04T01:32:30.265Z" }, + { url = "https://files.pythonhosted.org/packages/1d/4d/54e52c349e7a88d5c97cd04d78bd6cf3f4ac5e92b14bd0af9bde8a0d8b66/sqlalchemy-2.0.57-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:88837e22b43cf5e22070deb68c5cc77916dd2a231d8b9dc91ac8e5afb96a2c4f", size = 2193783, upload-time = "2026-11-04T01:05:17.978Z" }, + { url = "https://files.pythonhosted.org/packages/70/3e/76130164a383b7d4d3bb096cb44cfcccfe626cc5b3d5686d8fa7bdae7881/sqlalchemy-2.0.57-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:351b703ee93952f38f2e51e0781e1d4618602850252e68344b9dd28dccc2bd08", size = 3411394, upload-time = "2026-11-04T01:55:57.974Z" }, + { url = "https://files.pythonhosted.org/packages/67/7e/3d824c3051d89c42c453825435f9a9923ecb74ccfff5e22a3738d7e921c5/sqlalchemy-2.0.57-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc60f5a5f1ec190dcb1f5a9b9cf8f64d13f640ee429ee09fc0939ae9acaa6cc6", size = 3429699, upload-time = "2026-11-04T01:14:06.442Z" }, + { url = "https://files.pythonhosted.org/packages/8e/84/cc3c4c7162db679a85ee71a468d2d12efbd39cd38fd6ed5fbb391969d8a6/sqlalchemy-2.0.57-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8c2233997b87eed1f6664b047c6ff95a8a667ac46c053b29ed104d4df6210c1e", size = 3360826, upload-time = "2026-11-04T01:55:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/80/53/de3fb4d74ba8a597b3a2088a700eacba09b7048091ea18c009b6abad0c2b/sqlalchemy-2.0.57-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9815b64911596a3bf106b8ef4671a98b461e78cb668b4fa16a52caf44a3011d0", size = 3396007, upload-time = "2026-11-04T01:14:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3c/5bc3441094dde95e72f285bc50b50a37147c1946a2bfac99ee1b7b17faf0/sqlalchemy-2.0.57-cp313-cp313-win32.whl", hash = "sha256:239bd74da3bd9f5882b90361ff81232b6d8b85d53533da5a75bf54b140a964dc", size = 2148468, upload-time = "2026-11-04T01:10:37.915Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9c/0929d883270cc3c22f649d3d3690f6a7ecc9595b662bd3db410f0fc24d3a/sqlalchemy-2.0.57-cp313-cp313-win_amd64.whl", hash = "sha256:1fe4190e1673ff5415c691221864a61ca393653fcbc6f7c55a01b69d5a4c60c3", size = 2176183, upload-time = "2026-11-04T01:10:39.692Z" }, + { url = "https://files.pythonhosted.org/packages/87/87/beea25a26258782d793edf7a93d6616a6c7fc3877fe0d8681a72159279ed/sqlalchemy-2.0.57-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cae8591e095d25c2b83bd0bb2a9c973e493378267fd6116af91d8bb8d0c1016c", size = 2195350, upload-time = "2026-11-04T01:05:20.194Z" }, + { url = "https://files.pythonhosted.org/packages/81/17/1e4b4dccbf7b51bd9db01c3573fd9ed57f233ea929b0ab283341d666f090/sqlalchemy-2.0.57-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:060e698953af83dab5aeecc09d14122857c6dc69e288a8fc93c4487529b056cb", size = 3407307, upload-time = "2026-11-04T01:56:01.462Z" }, + { url = "https://files.pythonhosted.org/packages/37/12/103eee964f27b47b274a03fac580a04acf95547040e1dd0ae75f73336f3a/sqlalchemy-2.0.57-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23af494d0e22c51185d2d93b9a98666042d17a518df2731787dff7f20ee438e4", size = 3415100, upload-time = "2026-11-04T01:14:10.684Z" }, + { url = "https://files.pythonhosted.org/packages/34/38/6178719237766b22107e74dfa313e0475740e24bbd0a695accd310c533c1/sqlalchemy-2.0.57-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:794c5fa765363ce59f1e081fdab87ed7b30a0d775f7f7d379422bc2f53818bcb", size = 3355232, upload-time = "2026-11-04T01:56:03.453Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0e/f66a798f2a860ef69a60bdc6b36ba842f3518579995152568b527ac69573/sqlalchemy-2.0.57-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:294f6cf8465e0c5f1a00d6d3cb1a265737f3321811d12a57f4bfb66886e78662", size = 3385925, upload-time = "2026-11-04T01:14:12.746Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/8c5dba09ca96e6d3ac6167df8744282929d1d66e2c8a44b6ab77d83a76e6/sqlalchemy-2.0.57-cp314-cp314-win32.whl", hash = "sha256:adc6b7fc740593137b3b00c3f8c5330e891c11f40df7a3aecf942ad90c4b808f", size = 2151701, upload-time = "2026-11-04T01:10:41.417Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b6/9818ea27d43dc93da18bc2784b9a90de241f6a430c5110b01cd627b3b9c0/sqlalchemy-2.0.57-cp314-cp314-win_amd64.whl", hash = "sha256:88eed162ae664cc09808241dff304bc3ac5f57e2cf7a3cb9e6165df1ec243544", size = 2178962, upload-time = "2026-11-04T01:10:43.31Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/eac8949a2e23f8e5a03ce4e0f7323ff46fefcc980772a9e26c5a5ce4ff1e/sqlalchemy-2.0.57-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0ce98756f4079d5c5dfab7adb690a654cdadb1d1208024f239338627cfc44f31", size = 2218385, upload-time = "2026-11-04T01:02:24.037Z" }, + { url = "https://files.pythonhosted.org/packages/3c/dd/79ff11afb43db729628fc880aa0ea69078e79d5c29ebff74ec62b202ccce/sqlalchemy-2.0.57-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24a5c21c4263e7dafc1652d98d46375d7b38c490ccab41cf00d995ec9fa02b3", size = 3671084, upload-time = "2026-11-04T01:05:50.828Z" }, + { url = "https://files.pythonhosted.org/packages/20/04/d4f0b31d78dc36aef940a88a94eddc133867c75e66c791dfd8e3237aabc4/sqlalchemy-2.0.57-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e15997df22f3581ff38744afc39d024784eb90bd6c2c41c4387b116663021e87", size = 3634075, upload-time = "2026-11-04T01:54:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/b45b0f5319c9b663fd830417ab0a92a9ea31f2597eea67c93d7784e5b0b7/sqlalchemy-2.0.57-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f90a5194a25c9b611d871df770802b1b6e4915bef16cfdd398ce13bd0379a266", size = 3573973, upload-time = "2026-11-04T01:05:52.567Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3d/7f67df82fcb8e9a87759ac87bf86441fd616cf2d4c78bacc2f20e51f780a/sqlalchemy-2.0.57-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:71e53d9c6ea15c4dc435d936a0f72eeba218dc378c28d863cc6619b9a3ab8042", size = 3586325, upload-time = "2026-11-04T01:54:43.762Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/8fe6a0714ee4a965d5106c27768a76205023d01cd15717b31d9c19143786/sqlalchemy-2.0.57-cp314-cp314t-win32.whl", hash = "sha256:0e45c61722b17b41f6890d7c17a33b11270e2c334f4e7593774b0a5092fa08a8", size = 2184593, upload-time = "2026-11-04T01:07:44.65Z" }, + { url = "https://files.pythonhosted.org/packages/69/7c/aa20af18c93ae0635cb3a127f35e3e0c4ce92e2dda13fafc4a04af6ba211/sqlalchemy-2.0.57-cp314-cp314t-win_amd64.whl", hash = "sha256:8ecb63bdacc0f0b1e7cd37959b174c775a3d0f68bc6ad91702fe375c0c24d013", size = 2221982, upload-time = "2026-11-04T01:07:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/c9/65/131b250c444c4fe662ded3c565f468642a1e068296fec7eb7a381c2b721a/sqlalchemy-2.0.57-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:38eaae2db34b1f865cab99a40eca2e901a8979128b0ba5bee804b189cb71d03a", size = 2202112, upload-time = "2026-11-04T01:06:09.202Z" }, + { url = "https://files.pythonhosted.org/packages/0c/77/900d0235cda517d99329b561be21480d6a5c1a3421aade399bbf23fa3569/sqlalchemy-2.0.57-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444b6f14e65ff76e411d59dd16e2aef646d2c957afc24a49510149a28fc9dc33", size = 3369745, upload-time = "2026-11-04T01:11:38.226Z" }, + { url = "https://files.pythonhosted.org/packages/11/71/3d3a61d158549f978798c80e81a9cd3992f1da3420dc4617f7d70ef1698a/sqlalchemy-2.0.57-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8bac610e7dcc1a5969b82f0788bab5c5653dd08c5229247a14bf0de5cd20b4f", size = 3368279, upload-time = "2026-11-04T01:15:47.062Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f0/49b472325d47bdcb68b9a1b4724216d0d9003cb12a10e388ce6c02a8ee67/sqlalchemy-2.0.57-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0a00cd2d0378f8951e1c147b3bd5a45538e9bff6ebe9bd08295808755dbd9a2b", size = 3320387, upload-time = "2026-11-04T01:11:40.058Z" }, + { url = "https://files.pythonhosted.org/packages/29/e3/6c3991dfe4fcf3d01b2000669da55e1184e606c469e8b439dfd6212fdede/sqlalchemy-2.0.57-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bbfac8d64ebdf30b1912d8f1f9f96fe99bef10669f2533ecac1a0f585e956ff4", size = 3347842, upload-time = "2026-11-04T01:15:48.653Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e2/b5e61f5765dd66f6410ee00f1683dfd75ac096b84ca70d5781f8be963002/sqlalchemy-2.0.57-cp39-cp39-win32.whl", hash = "sha256:35713e7ab2f7e85bfee59bda25bc483597600a5756e6367918ebb5e63a39ee0b", size = 2155788, upload-time = "2026-11-04T01:14:50.688Z" }, + { url = "https://files.pythonhosted.org/packages/6f/64/fe72c383da986d6663e56c3fb0b921b402774566b5d7477112f4d2288773/sqlalchemy-2.0.57-cp39-cp39-win_amd64.whl", hash = "sha256:7215ab39053d065b589ab7f78c84226fae57b76d28e832be2e8435c416c1c2d0", size = 2181970, upload-time = "2026-11-04T01:14:52.55Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/ed54ed17987f65115360b124ab943d955c04906fd589411627805afe7b57/sqlalchemy-2.0.57-py3-none-any.whl", hash = "sha256:e31639a78045134a39e14f287b647fd9537cdd71978ea9c11bf7d905d70e473d", size = 1967625, upload-time = "2026-11-04T00:59:28.703Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.1.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/e7/2855174facf54a83b4fa3eddcece3631c6bdae43605dacd69cc3b6794319/sqlalchemy-2.1.8.tar.gz", hash = "sha256:8bc4081dd7c17c073f2384ad0f5ee968e63b43507b2e024a3523c000a06d70da", size = 10627538, upload-time = "2026-11-04T01:18:31.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/98/8f442bc4ef6cd6bada9a5b945650d8c58fbfe8aabce46554ca9db61aac9f/sqlalchemy-2.1.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ce7c1e3a45ce6d64ec3ed06ec6c0e288711482f1990115c9b4cbe4da2626a424", size = 2475845, upload-time = "2026-11-04T01:25:24.965Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/5a8045d0b835c608b366e95f6991e47a1cd8bed2f136e2550d54aa26bf12/sqlalchemy-2.1.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b592cd60907988c30186d6a1ab157ac5692d2fc505d4e7f872c56acf69835149", size = 4642069, upload-time = "2026-11-04T01:44:26.379Z" }, + { url = "https://files.pythonhosted.org/packages/a7/99/b3735bdc14a913c292f9fb8e0aaf8d0cd24040edbf8f40ef9e4867719438/sqlalchemy-2.1.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99577caccafc97b712f250c0e251546e97e6e64676213cf6085a63386fa48595", size = 4664881, upload-time = "2026-11-04T01:49:00.831Z" }, + { url = "https://files.pythonhosted.org/packages/8b/1f/5784c63b58ea716f53e0efd65a5f97660fbacc40e35cd8ef9ea29fa3e5d6/sqlalchemy-2.1.8-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aea7192a4a21d0b6999a4ebdab76af6d31d8c2d7397b2ef1008301c63e31ab78", size = 4394153, upload-time = "2026-11-04T02:14:43.039Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fd/cee2af0f06f7d6d6ae06496710df6ed525a7ca003eec22e5e058fd6e1c96/sqlalchemy-2.1.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0b239d72e4a6b35d5ddb7e6f3442e4c350def6165b1adabef65b8fa9a025cb6d", size = 4580783, upload-time = "2026-11-04T01:44:28.645Z" }, + { url = "https://files.pythonhosted.org/packages/e9/c8/ab93f760237943c7b4ce2c64d02aabb5ba0015f5c8f1dda0c5736e770732/sqlalchemy-2.1.8-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:656bc82cf54f90c2e3909c5f9416710a46475deac462607bf7ee2ef9f47900af", size = 4390493, upload-time = "2026-11-04T02:14:44.754Z" }, + { url = "https://files.pythonhosted.org/packages/28/f1/e5bd76bcb98d11808e0c33a1296b45dce4bf40cd83f26987ee26cb099f40/sqlalchemy-2.1.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:36e3192f86fbb32d7267af6a05e36d1ad4c5ede3d7c17165bddb6a7585b3ddad", size = 4635589, upload-time = "2026-11-04T01:49:02.999Z" }, + { url = "https://files.pythonhosted.org/packages/fc/af/cd0c84c27b81fafbf7568a8d63c52ad53bf206f574ada445c55c70eca6ed/sqlalchemy-2.1.8-cp311-cp311-win32.whl", hash = "sha256:8a76bd79630e18b1defd176b889dd16cd88a44c73a3660a29594ff110dc626d9", size = 2392348, upload-time = "2026-11-04T01:36:59.644Z" }, + { url = "https://files.pythonhosted.org/packages/0d/33/108f557dba8278bb910b76437b3f93fed9a6ade4e01379dd435ba330ddd6/sqlalchemy-2.1.8-cp311-cp311-win_amd64.whl", hash = "sha256:e0ade636fb77c4e4156edd584292d2b1d2033fe544da8288590561257c99bd03", size = 2442798, upload-time = "2026-11-04T01:37:01.174Z" }, + { url = "https://files.pythonhosted.org/packages/63/64/eade018796944eee77bcbcc28a627f7c48058e7d4dd757285051f176d50a/sqlalchemy-2.1.8-cp311-cp311-win_arm64.whl", hash = "sha256:a6dcab1a7200ef01f9b01cfc351673973c0c856264dd396ba71570c58916339c", size = 2407879, upload-time = "2026-11-04T02:23:56.045Z" }, + { url = "https://files.pythonhosted.org/packages/c2/96/52f82637a6c73d419a8dc9100f6c092e10ee15344af03d36f12ad684efa7/sqlalchemy-2.1.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8dd7ae94cbbb6e58d1c03de044b8029d571f445feafb6fafc1c8a21598706f8a", size = 2479028, upload-time = "2026-11-04T01:25:27.726Z" }, + { url = "https://files.pythonhosted.org/packages/21/23/e89861d65c61889509c363a1fd183ec1a0b42cdfa4900a64bc4f3aed768f/sqlalchemy-2.1.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:deaa7d41f95e085e4962d5d65f97a13e47c51c631f7e4c080c332c5c5c0da786", size = 4685369, upload-time = "2026-11-04T01:44:30.443Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6d/fbae68a0c2306cdb04b510021d62d278581192da4276e5c08aa5836cf7ee/sqlalchemy-2.1.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b9844ecf59d389648cf3ce2b387475f81b02c2d32f16b95a554ca535a68ba13", size = 4732418, upload-time = "2026-11-04T01:49:04.883Z" }, + { url = "https://files.pythonhosted.org/packages/6e/89/c79c8715ad59ab6204251a2c4c3c6c7631c5837d17c4e4c70e9a382a680e/sqlalchemy-2.1.8-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0aace49c6d0fc854bf600037df099ddc375ac2673747b6b0f29cdc6a3f96f37d", size = 4385724, upload-time = "2026-11-04T02:14:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/c8/32/5ee7e6fc18a175c030a90d74334a5ef5d6dd64ee783af95ee1ad79e875ee/sqlalchemy-2.1.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0bf96e2ef5b5395adc7120de938a457d820fd27bcd9cdb300964140906e6777f", size = 4608657, upload-time = "2026-11-04T01:44:32.283Z" }, + { url = "https://files.pythonhosted.org/packages/a2/dd/4de9fd886a3535feb4e6e2d72a1a4a308c8f2127d9b3c96f8f42e0e01e93/sqlalchemy-2.1.8-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d63dfd83282891c91f1dce85ce211cccb5c06573830a60f8d1a747164975ed4b", size = 4378472, upload-time = "2026-11-04T02:14:48.063Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fd/d1d4af3ed1d951f533eb2f90b17dd7a05c826dce18a628e9f4b0df1a1b17/sqlalchemy-2.1.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a092e37949aec29964e3692a40b40380604fa7dc0186aeae372f64a072e9049f", size = 4679299, upload-time = "2026-11-04T01:49:07.323Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/cbfc9aad8f8f731304e99a0821dba00435e5d32ef2253929bbe0614774e8/sqlalchemy-2.1.8-cp312-cp312-win32.whl", hash = "sha256:09a78681468ecae9b3e65de8afe20e74f36bf5c5d63c4c26ffc28b05858e3bdf", size = 2389528, upload-time = "2026-11-04T01:37:03.097Z" }, + { url = "https://files.pythonhosted.org/packages/01/42/2c2622f1c7763815b4ac5bcfdb4e8fc668978033a522ef539f135ebe4002/sqlalchemy-2.1.8-cp312-cp312-win_amd64.whl", hash = "sha256:06ba41b90d1cf018cbe9a5474958dbdd2a72ec946ace8d314021d24e42ae2f39", size = 2442167, upload-time = "2026-11-04T01:37:04.498Z" }, + { url = "https://files.pythonhosted.org/packages/93/a7/c8b4eff4ab7e00a6918bd8d6be10f411d2aa2e8d1b58a97f20fa7b1a7e80/sqlalchemy-2.1.8-cp312-cp312-win_arm64.whl", hash = "sha256:4523d270fcbf771ab3e25942485530b0209567e3368036ebd6c5a6636f6856aa", size = 2401768, upload-time = "2026-11-04T02:23:58.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/b8/690d2f08f20e462d7e06997a5affa03c5c1b18a8e2d843c59ec00d0dd75b/sqlalchemy-2.1.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b85a44c87df33eed672d062d43eb9f01c7162a755cee5bce11c995c4fceba2ee", size = 2472959, upload-time = "2026-11-04T01:37:46.803Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fe/500d2be7429dd6452f587950b65111f50cacc1ad038a651aaff0c8617fb3/sqlalchemy-2.1.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1a12d2b8411bee0cd8d6b3aea3282ecef1dcc61f0aafb094b9849ad559f7301", size = 4610698, upload-time = "2026-11-04T01:36:50.73Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b2/80d02cc022dd194163301e1a76cc8a559d63991fc39bf6ef34ec67a93893/sqlalchemy-2.1.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b710c896f3e3c59cb70b5bf73c8e28e13e7c60a2e28ea478bc182198efc6725", size = 4662561, upload-time = "2026-11-04T01:51:04.326Z" }, + { url = "https://files.pythonhosted.org/packages/84/77/62336a77c21ffe7d9578ac69a1104616b4b1d5de655be5490b27b7366beb/sqlalchemy-2.1.8-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ce0e9058f3819cd0b7db8191941dd19ae82dc1b97dc0617e9df25984d00ee61c", size = 4325520, upload-time = "2026-11-04T02:01:58.726Z" }, + { url = "https://files.pythonhosted.org/packages/b5/5d/2a3f823121be04ff238b469839f61956aaefb8aa92f885a37b5e67ded73d/sqlalchemy-2.1.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5af2275f5607c10bfbf69de4339ecc98d17a8ae5ae67f6686c30d6a6cc52e5b7", size = 4533812, upload-time = "2026-11-04T01:36:52.56Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7f/701896d7011be02b04701a5bf93aa2ac2d69290280f3cadd0a88c99d1ebb/sqlalchemy-2.1.8-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:205dfb38f3d8bac38dedec30110160db0331d71c7f04e6180e5b4e07d1debf0d", size = 4326786, upload-time = "2026-11-04T02:02:03.14Z" }, + { url = "https://files.pythonhosted.org/packages/05/f2/b4c75b63936f5c2fba05aeec79c18319d98be292014e28136fbcbd098b38/sqlalchemy-2.1.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:28876f64e7a5458acc972013e5366e0e53c59ba04c6a4a4fdeece58318aaeeaa", size = 4621980, upload-time = "2026-11-04T01:51:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/c9/74/c005ff243ddcdf0d48b95a362fecc004a40b4a5c2e893c10e8a11241a997/sqlalchemy-2.1.8-cp313-cp313-win32.whl", hash = "sha256:10a62fe79b1f4861760e50aa968be71348b6a0ec6914e20221a2d8c506925280", size = 2386698, upload-time = "2026-11-04T01:46:58.251Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/94b309df5fa7ff7eb45d06bdc20a54eeca5b9e9d15121b9a9eca064c7fa9/sqlalchemy-2.1.8-cp313-cp313-win_amd64.whl", hash = "sha256:db7777f7ae23175aaef0778d718d5d6f5005bc8979fc3b9b494455a0556cc050", size = 2437193, upload-time = "2026-11-04T01:47:00.074Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e6/73e80f32b494263af266a353d8299422fb8b3b9db4b6a385787090948ddb/sqlalchemy-2.1.8-cp313-cp313-win_arm64.whl", hash = "sha256:dc1ab4740247b7601ad44a992a9571389bc5e5e4a8807fa23485767c3fd5a08d", size = 2398094, upload-time = "2026-11-04T02:24:00.322Z" }, + { url = "https://files.pythonhosted.org/packages/96/5d/33ce9cf53c0a12774426d16f6cc5d2f258caf05272cf4652afd726085e73/sqlalchemy-2.1.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e08e91fbb02d7d659586d8f778fbc4d388c02e2592d245cc98b454ad9169d42", size = 2476226, upload-time = "2026-11-04T01:37:48.28Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/c0614f64e004f38590289aeb868a1e1405f362ae62801f7dfb9da77cc2c4/sqlalchemy-2.1.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2895f18e332143a70f4e226d789beaa6ddb6e6b5fb9568e5d9afc3a6dcad7d03", size = 4602752, upload-time = "2026-11-04T01:36:54.205Z" }, + { url = "https://files.pythonhosted.org/packages/a5/7a/471d5a50c82102f12cf7a95501e42ff8584b5cb30b16e5413584264a518c/sqlalchemy-2.1.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9587623885c13b7c53354fa39a5edc7cf5388dec708e4e6d04e7b6868941a2c6", size = 4632728, upload-time = "2026-11-04T01:51:07.862Z" }, + { url = "https://files.pythonhosted.org/packages/f9/20/086bf452708b606e6868f026d81b08eb8a5fb080e60a141f3479dc3aaae7/sqlalchemy-2.1.8-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1da7b54a7b3da71abbee002c0854f843f43452d778026db7cd65ff6432806540", size = 4326031, upload-time = "2026-11-04T02:02:05.197Z" }, + { url = "https://files.pythonhosted.org/packages/6a/77/fa45f4a92f3648f4d4a96588ad5fc651b2388dd60fbe85b5f1dc44377178/sqlalchemy-2.1.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d1dcb218893c207ea8faf8729f4f3a3c5d1641c5a9f11134ededc8a06daf647a", size = 4533326, upload-time = "2026-11-04T01:36:56.194Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2c/ae5f2ade15c494dbc41254896ececc28408bea6389807b4be71727580d43/sqlalchemy-2.1.8-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:06e30b673ec8a89b398e95052c2c42d9e0bc0163940cdb7d32f36a79c0d12f3b", size = 4326509, upload-time = "2026-11-04T02:02:07.305Z" }, + { url = "https://files.pythonhosted.org/packages/ff/eb/4396c73fd064c435cf52c0f988d6356d6cd4435a8c037ea47a82f47764f6/sqlalchemy-2.1.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7508d657d624d88508fa5e6d05487073edafed57a222d4a8bddbe8a7aead3fbb", size = 4593826, upload-time = "2026-11-04T01:51:09.516Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ac/9938f4ff6dd9b5179bd2a9635a7d627cdf8b526975c4076eb5267643a9dc/sqlalchemy-2.1.8-cp314-cp314-win32.whl", hash = "sha256:e25fc1e5df892b800bb1cf5d9e5f7005620976f02ecde90859decea1aa85a1e9", size = 2393483, upload-time = "2026-11-04T01:47:01.966Z" }, + { url = "https://files.pythonhosted.org/packages/66/b6/4bfee0e40608186f28ac6dc69120189b5e163a297bf71bfb86aa97b789e8/sqlalchemy-2.1.8-cp314-cp314-win_amd64.whl", hash = "sha256:4b8c3559fb4382136a45b12a9546055781bcc5898a6b223ae8bd9d873c210df1", size = 2444576, upload-time = "2026-11-04T01:47:03.635Z" }, + { url = "https://files.pythonhosted.org/packages/db/d6/6b21824dc390a05a3cbbf158be78579d5f3e68ad47a0dff1fb2d4f351950/sqlalchemy-2.1.8-cp314-cp314-win_arm64.whl", hash = "sha256:e1e7515eca2f4dfc48385edb61964c96bfb7b8efc2e6a2421da3ac07e29d82cd", size = 2409007, upload-time = "2026-11-04T02:24:02.266Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/fb9dedcb573052d1779b04eb93a79139689c58f0b120e35cdec589f17684/sqlalchemy-2.1.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:abec8d471bb08ecae25b08b24284e11439b215ec9d09ffbbddb23f9b3a8b425a", size = 2515693, upload-time = "2026-11-04T01:29:39.038Z" }, + { url = "https://files.pythonhosted.org/packages/cc/99/fe499115217889b56fc683a7159a9a5c06061018872cbb2333b95b4e9d38/sqlalchemy-2.1.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbc66d47ac0ccfc73f99980533502077a95c3252c1a8fa4da446ad597ddb2ba7", size = 4933839, upload-time = "2026-11-04T01:38:02.897Z" }, + { url = "https://files.pythonhosted.org/packages/68/ac/a588c17d5038a219fb12c39a1d580b0e4f407a9f1bd9990967d72a83b0dc/sqlalchemy-2.1.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2feca3677281ed655a68b64aee68262cb867675cae8ef5066edd065a7fac4106", size = 4844863, upload-time = "2026-11-04T01:52:26.708Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/b382ad5e99b5796722048bee2a8384ea186fc72283a7e48d20b579ee5363/sqlalchemy-2.1.8-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35e930696d2add7671e4ef7738e90a54d7cbc31fa0da8efe3c4a76a684c25cfa", size = 4520365, upload-time = "2026-11-04T02:07:00.059Z" }, + { url = "https://files.pythonhosted.org/packages/79/8c/c74ea91159a1be6649c92cf4e8dd50ca596f926acea075ca4ada8e26392b/sqlalchemy-2.1.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:141f4bb653b9acadd33edd2c524ab85ec97cf5d781eea0e6f6c4fb8b517203a5", size = 4789885, upload-time = "2026-11-04T01:38:04.835Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5c/1bc57f06a890a70f345024076de2fb38e6025384260a1daa0708d1bddb24/sqlalchemy-2.1.8-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92604cdd92b62ea56e8b04d6ce40a939a18a75c87b7a74e86cd4c562178afccb", size = 4520407, upload-time = "2026-11-04T02:07:02.525Z" }, + { url = "https://files.pythonhosted.org/packages/10/3e/ef1ca7d42400c82cef4cdffe4b6f0781ee329a7ad732dafa9a2093688c44/sqlalchemy-2.1.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:92f87e53ded137e29231e05409eae97fcec0d0289378e98e26d0d327a1a1bf03", size = 4781343, upload-time = "2026-11-04T01:52:28.59Z" }, + { url = "https://files.pythonhosted.org/packages/73/f0/a1d6c87da1e8a31824d4b4d6310473adcc3cbcd5292e0fb59d371a510469/sqlalchemy-2.1.8-cp314-cp314t-win32.whl", hash = "sha256:8af07d4dc094fb81d218c05176bba00e3c75d67fbe813b47b8b3593a27add533", size = 2452371, upload-time = "2026-11-04T01:54:51.19Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8a/141a2d5a62bcc5b0826d9b60ab7b5120298b720fdae103d722dd4eb7e0b0/sqlalchemy-2.1.8-cp314-cp314t-win_amd64.whl", hash = "sha256:aaf1e9adb18645bec3eeedf89342d2970103399207123b096284c3741b64217a", size = 2520395, upload-time = "2026-11-04T01:54:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/ba/24/d3d76cc6ff784add4022a01657093648505a98d42933f26531c0e66c956b/sqlalchemy-2.1.8-cp314-cp314t-win_arm64.whl", hash = "sha256:d2dc85ccb5e224f0c5be165d70a1d04a3a754afb53fffddc4c410509fabae430", size = 2438358, upload-time = "2026-11-04T02:24:04.262Z" }, + { url = "https://files.pythonhosted.org/packages/0d/cb/0bebbd26410972fcfba11df95fb5e0c2f5c8c95d6d4b6bbac2f722211bd2/sqlalchemy-2.1.8-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b4fb02b7cb4e7c76de89e13824b30bff45b2fbbc585a468b9a9a1588e5ef744", size = 2476243, upload-time = "2026-11-04T01:29:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/be/ad/088be566c877ba603e62946ab1517726eb306fc20f1f6aa775f420367fdc/sqlalchemy-2.1.8-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:befc454b74ef9584e4c06471a6caaf20ef2b43659e152b50f5c3d61832073f50", size = 4606832, upload-time = "2026-11-04T01:25:35.685Z" }, + { url = "https://files.pythonhosted.org/packages/bc/56/2168d4a54e8f4cc26ccd7a0dbaf6c60bda1a6fa3fe9d8de9b4ff7ff85572/sqlalchemy-2.1.8-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:08634599ccbeee6528b645f08516d1781a009422b22723a27b3331e26955d95f", size = 4642597, upload-time = "2026-11-04T01:42:21.853Z" }, + { url = "https://files.pythonhosted.org/packages/8c/39/56139497cad31c8333e97b375e085d6e26ec92c6016fef123d30e6de6aaf/sqlalchemy-2.1.8-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b63166df937ad4948a4a35ba67106a73b529c132e46a0ffbe82076abb0bbe57", size = 4346628, upload-time = "2026-11-04T01:52:52.2Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/7eed18219ec450341a7b907a1af1fd39dd2016e52369a7f1bac66fcbc146/sqlalchemy-2.1.8-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:399082c1cc5ec4ed117b46501194ad3c99ac89902679916caed0047018e2ceac", size = 4535914, upload-time = "2026-11-04T01:25:37.543Z" }, + { url = "https://files.pythonhosted.org/packages/c1/33/7d8d45c6f1068cb1b1bbf9eeaf4489d2a61d05d4c23dfdf01505803b7561/sqlalchemy-2.1.8-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:cfd71226e277e4f54bec6b27652c04c5498aa9a913cddaf32d17126ce058ac90", size = 4346552, upload-time = "2026-11-04T01:52:54.293Z" }, + { url = "https://files.pythonhosted.org/packages/9f/84/cc5b3eeb04723d22031052eac0bbee73c4506eea461d8d3fcacc910f154d/sqlalchemy-2.1.8-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e4e2bfa6ce7916776afd3db02006c56d90a7362ed72774afd9071ffe03cd8188", size = 4603058, upload-time = "2026-11-04T01:42:23.938Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3d/bdba8fd3bad5a9fa71097ec3ee7cfc053ccb744c4b2f5293315b3eeac632/sqlalchemy-2.1.8-cp315-cp315-win32.whl", hash = "sha256:47e2bbdc32e7bb26ecb796a60bacfe5fa673f01d0a6f1f06377c201495159a6b", size = 2393230, upload-time = "2026-11-04T01:36:44.036Z" }, + { url = "https://files.pythonhosted.org/packages/67/ac/d4cb82291d6ac2643aebf3e2793024d3933eaedbee5d37ea2d1ae43b8fbe/sqlalchemy-2.1.8-cp315-cp315-win_amd64.whl", hash = "sha256:acc8de93461dd9259c5e5eb42c26199a94ea2631f49a11d659d00e375294d48f", size = 2444661, upload-time = "2026-11-04T01:36:46.659Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f2/57d59f7c26fbb75593a9cf81918b507501227bb2f22617a4c2ba2eb8aa14/sqlalchemy-2.1.8-cp315-cp315-win_arm64.whl", hash = "sha256:bf31d4e8f8dc964d7a314a26b5d7f5cda245f9acab0145fd4fb19b7605803e94", size = 2408863, upload-time = "2026-11-04T02:24:06.288Z" }, + { url = "https://files.pythonhosted.org/packages/83/26/96dc5ee6de17d11e1eebea88a7fe7079965b52a4c168b6ffd2ba84306a5c/sqlalchemy-2.1.8-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f50e34ebdf4ad7c96d89cfb05418e11ed23d3ac46f1f59f3d2e1d8da8091a0eb", size = 2511956, upload-time = "2026-11-04T01:29:40.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/70/a121c4210a0f8dbba4f23251aca09b6589245fe1ac39d9efd125f6f99d1a/sqlalchemy-2.1.8-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3418927e5f2a5bab86ea7ceec378fe7760feb736d200f0027d22660c5720c5b0", size = 4911070, upload-time = "2026-11-04T01:38:06.483Z" }, + { url = "https://files.pythonhosted.org/packages/6a/90/ff1b3902f31c6bcd6e6aeee3c025f53ff64da8a82c06fc9331cda42adcee/sqlalchemy-2.1.8-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:862af4d5858840f2f444821d86c38c75d13288fc18ab114f0f876644620fd3ac", size = 4860065, upload-time = "2026-11-04T01:52:30.75Z" }, + { url = "https://files.pythonhosted.org/packages/bf/8b/99eb6ab5f8b61f718dbb2a1ce4ea52dbf0729d61ad69e614e54f825e566a/sqlalchemy-2.1.8-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68eeca1191731d101bae85e3dbc6447c66ea6768d1f49bae104e2875e9e24814", size = 4511169, upload-time = "2026-11-04T02:07:04.194Z" }, + { url = "https://files.pythonhosted.org/packages/9b/81/2eadf593365e43e06d77ff0a3c05b8887f7f2834a3f8204afe2a5c02b106/sqlalchemy-2.1.8-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a4fec84bfe7a466ce29d2c01740b29217ec6a64c7da10975475649bf1e1a6513", size = 4766018, upload-time = "2026-11-04T01:38:08.056Z" }, + { url = "https://files.pythonhosted.org/packages/fa/27/f5bb95135214a28b453f22279bab98212d2c4fd43aa7efd676fb1f66fdcd/sqlalchemy-2.1.8-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1fc68555af5ccfe5c6637e84a556319a82ff21d9150d3208533722bf3108db17", size = 4510843, upload-time = "2026-11-04T02:07:05.848Z" }, + { url = "https://files.pythonhosted.org/packages/37/a9/54e16b502910dde37801b56210c7bc2684e18d931a0ed11c1d66a8d1c7b3/sqlalchemy-2.1.8-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:09d190d37a002bd2984e3ebdbaf81f7530bb848736860bbd22e81cf417b067bd", size = 4788590, upload-time = "2026-11-04T01:52:33.138Z" }, + { url = "https://files.pythonhosted.org/packages/12/1e/2e06f29fb80dbd8e14d552c72b6c23ebdcd337a172a675eec48667f4ed32/sqlalchemy-2.1.8-cp315-cp315t-win32.whl", hash = "sha256:92810a47ed62fc17088e3d7f3c8bde407b53bcd1c113a50e2d9433631bb3c230", size = 2449214, upload-time = "2026-11-04T01:54:54.18Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8e/362e7ac1bc6e8a69d72620cc2acbda80a592021509dcc0c3abb2fcfe4c3a/sqlalchemy-2.1.8-cp315-cp315t-win_amd64.whl", hash = "sha256:ffe69b75459d90529f603d9594255c41d6a503f69220740510373374198735f6", size = 2515542, upload-time = "2026-11-04T01:54:55.764Z" }, + { url = "https://files.pythonhosted.org/packages/35/0f/f7afff708c464e9a35d429e3faaaebdc4d253b7230f8942a0acc4a4e0283/sqlalchemy-2.1.8-cp315-cp315t-win_arm64.whl", hash = "sha256:105a13be11045347a78e1599adbcdad895c32067696bca9ba02754ac32f82699", size = 2433845, upload-time = "2026-11-04T02:24:08.211Z" }, + { url = "https://files.pythonhosted.org/packages/a3/1e/6eba6770b626a991e58de306385d414b9f3ae780ae60c61aa1d0841dcaaf/sqlalchemy-2.1.8-py3-none-any.whl", hash = "sha256:69aea77b5d8a64e95d01fd7ec54985c84b4a8ef3cac3108e9271145441400c1b", size = 2067639, upload-time = "2026-11-04T01:30:31.914Z" }, +] + +[[package]] +name = "stackencrypt" +source = { editable = "." } + +[package.optional-dependencies] +test = [ + { name = "alembic", version = "1.16.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "alembic", version = "1.20.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "attrs" }, + { name = "psycopg", version = "3.2.13", source = { registry = "https://pypi.org/simple" }, extra = ["binary"], marker = "python_full_version < '3.10'" }, + { name = "psycopg", version = "3.3.8", source = { registry = "https://pypi.org/simple" }, extra = ["binary"], marker = "python_full_version >= '3.10'" }, + { name = "pydantic", version = "2.13.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pydantic", version = "2.14.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "sqlalchemy", version = "2.0.57", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sqlalchemy", version = "2.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.dev-dependencies] +build = [ + { name = "maturin" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", marker = "extra == 'test'", specifier = ">=1.13" }, + { name = "attrs", marker = "extra == 'test'", specifier = ">=23" }, + { name = "psycopg", extras = ["binary"], marker = "extra == 'test'", specifier = ">=3.1" }, + { name = "pydantic", marker = "extra == 'test'", specifier = ">=2" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8" }, + { name = "sqlalchemy", marker = "extra == 'test'", specifier = ">=2" }, +] +provides-extras = ["test"] + +[package.metadata.requires-dev] +build = [{ name = "maturin", specifier = ">=1.9,<2.0" }] + +[[package]] +name = "tomli" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/ab99b60ee52acd949684febabc3005d0045d0f66bebd9cdebd67372d26dd/tomli-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545", size = 163901, upload-time = "2026-10-07T12:22:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/bc/00/ee01b7ed4579180fff07142d290257f25ba786f23f3ec6005f620933c2f5/tomli-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef", size = 163756, upload-time = "2026-10-07T12:22:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/72/c2/4efebf65372f6583185f79799312109dddb61102d47e5c33dcfd1a297aca/tomli-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b", size = 268038, upload-time = "2026-10-07T12:22:18.135Z" }, + { url = "https://files.pythonhosted.org/packages/53/07/5850468e925d898abb36038666f9c333a94d2a223e802a8ba5b6d319d23f/tomli-2.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56", size = 276422, upload-time = "2026-10-07T12:22:19.567Z" }, + { url = "https://files.pythonhosted.org/packages/b4/87/f293984cdcf83c054196d4fd3dad44fc68ae55b4b8c44bc76cef360c3150/tomli-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1", size = 272616, upload-time = "2026-10-07T12:22:20.794Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ce/db582886b3c1219d3fec93ebd669332482e5aee7a91e0f7838d84f2d1759/tomli-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885", size = 276593, upload-time = "2026-10-07T12:22:22.12Z" }, + { url = "https://files.pythonhosted.org/packages/bf/72/7619b87dea4261fc27dd7b54c4461c129c1f7d9bb7ba3aec89c797a431b8/tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e", size = 101830, upload-time = "2026-10-07T12:22:23.651Z" }, + { url = "https://files.pythonhosted.org/packages/1e/74/220106da34502304b6751a2a9b8a9fbca6c3fd47e737a2e2e3da7c61c9db/tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8", size = 112742, upload-time = "2026-10-07T12:22:24.972Z" }, + { url = "https://files.pythonhosted.org/packages/27/99/7d9c8b41837a7773613e169504147375c157a290167aa59ad74a085f521f/tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980", size = 109332, upload-time = "2026-10-07T12:22:26.117Z" }, + { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" }, + { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" }, + { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" }, + { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" }, + { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" }, + { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" }, + { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" }, + { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" }, + { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" }, + { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" }, + { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" }, + { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" }, + { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" }, + { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" }, + { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" }, + { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" }, + { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" }, + { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" }, + { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/50/0195198eb1d75bab716bb0a813abea4cc394bfa17a92a189f021a34859af/typing_inspection-0.4.5.tar.gz", hash = "sha256:7f16c411e7930dc79596d42ab44672a3fe5f60eb34b781a1647e7872363120c7", size = 81194, upload-time = "2026-11-04T14:13:23.774Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/0c/ea2544850a7c135e15033a5c7a8e7211136184b7ae63e2239d557c2b3019/typing_inspection-0.4.5-py3-none-any.whl", hash = "sha256:bc210535442b39e1bf2805e0c7a17dfd6a4ace760c84c7f7d16245d23a6b1c39", size = 14874, upload-time = "2026-11-04T14:13:22.616Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/29/6a1391d31cd00ff9e927215a224a880e1e84e826b64ba80a499e51c074d8/tzdata-2026.6.tar.gz", hash = "sha256:85d2cd9c09f81f0bb29db27759fc83af4f1e620efb814f8c5bf8b4cc4feece6d", size = 201017, upload-time = "2026-10-28T09:57:09.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/66/9a06c2227fedfd4919f0eaaa8b94377d9730882a7e4f95823c9a331ab5e1/tzdata-2026.6-py2.py3-none-any.whl", hash = "sha256:0a10eec6e13ef16b627fabed9a5f2ba61a62b1c67f327305f4e7e412a70b4aec", size = 348210, upload-time = "2026-10-28T09:57:07.276Z" }, +] diff --git a/mise.toml b/mise.toml index c0a664fa8c..fbc690fff5 100644 --- a/mise.toml +++ b/mise.toml @@ -2,8 +2,8 @@ # workspace in Cargo.toml (the stack-* crates and the three node bindings) and # the Go module in languages/golang. # -# EQL (packages/eql) and protect-ffi (languages/typescript/packages/protect-ffi) -# keep their own mise.toml. A tool they pin there overrides the pin here, in +# EQL (packages/eql), protect-ffi (languages/typescript/packages/protect-ffi) +# and the Python SDK (languages/python) keep their own mise.toml. A tool they pin there overrides the pin here, in # their folder only. [task_config] @@ -363,6 +363,21 @@ RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --target wasm32-wasip1 mise x --env test -- cargo nextest run """ +# Fuzz the Python SDK's value conversion (stackencrypt-core's `values` +# module) both ways: a value the write check accepts must frame and read +# back as itself, and as the bytes the plain Rust type frames to; arbitrary +# bytes read as a passthrough must never panic. Same flags as the stack-* +# fuzz tasks. The fuzz crate lives in `languages/python/crates/core/fuzz/` +# (detached). The task is here, not in `languages/python/mise.toml`, +# because `fuzz.yml` runs every fuzz task from the repository root. +# It runs from the root too, naming the crate with `--fuzz-dir`: from a +# folder under `languages/python`, mise would also load that folder's +# mise.toml and install its Python and uv, which CI does not cache. +[tasks."fuzz:python-values"] +description = "Fuzz the Python SDK's value conversion, stackencrypt-core's values::write and values::read (libFuzzer, nightly, 60s default)" +dir = "{{config_root}}" +run = "mise x --env test -- cargo +nightly fuzz run value_frame --fuzz-dir languages/python/crates/core/fuzz --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" + [tasks."go:test"] description = "Format check, vet and test the Go module (languages/golang: encrypt, auth and the internal packages) against the embedded guests; needs `wasm:guest:build` and `wasm:auth-guest:build` first" # The body lives in scripts/go-binding-test.sh so the macOS and Windows CI diff --git a/packages/eql/crates/eql-bindings/README.md b/packages/eql/crates/eql-bindings/README.md index 37defa996f..5820687992 100644 --- a/packages/eql/crates/eql-bindings/README.md +++ b/packages/eql/crates/eql-bindings/README.md @@ -431,7 +431,10 @@ The table and the dispatch are generated into `src/v4/targets.rs` beside the v4 family modules (`mise run types:generate`), gated to the `targets` feature, and drift-gated by the same parity tests. The same generator writes the Go package `languages/golang/encrypt/eql` from the same rows -(`eql-codegen go-eql`, also under `types:generate` / `types:check`). The +(`eql-codegen go-eql`, also under `types:generate` / `types:check`), and the +Python markers module `languages/python/python/stackencrypt/_eql.py` +(`eql-codegen py-eql`, likewise). Both are outside this subtree, so neither +command accepts an `EQL_CODEGEN_OUT_ROOT` override. The engine's `TargetResolver` (stack-encrypt's `dynamic` module) is implemented over this module by `encryption::EqlTargets`. A program that runs data plans with EQL types passes `&EqlTargets` wherever the dynamic interface takes a diff --git a/packages/eql/crates/eql-codegen/src/go_eql.rs b/packages/eql/crates/eql-codegen/src/go_eql.rs index 6484b2d972..977a5fdc2c 100644 --- a/packages/eql/crates/eql-codegen/src/go_eql.rs +++ b/packages/eql/crates/eql-codegen/src/go_eql.rs @@ -145,33 +145,11 @@ pub fn render_go_eql() -> String { out.push_str("}\n"); for row in &rows { let go = go_name(&row.name); - let indexes = if row.indexes.is_empty() { - "no index".to_owned() - } else { - format!( - "the {} index{}", - row.indexes.join(" and "), - if row.indexes.len() > 1 { "es" } else { "" } - ) - }; - let status = match (row.reason, row.plaintext) { - (None, Some(kind)) => { - // The Go kinds are read as words: "an int32", "a float64". - let article = if kind.starts_with(['a', 'e', 'i', 'o', 'u']) { - "an" - } else { - "a" - }; - format!("The engine produces it from {article} {kind}.") - } - (None, None) => "The engine produces it.".to_owned(), - (Some(reason), _) => format!("The engine cannot produce it yet: {reason}."), - }; let _ = write!( out, - "\n// {go} is the EQL type stored in {domain}: the {family} family with {indexes}.\n// {status}\ntype {go} []byte\n", - domain = row.sql_domain, - family = row.family, + "\n// {go} is the EQL type {summary}.\n// {status}\ntype {go} []byte\n", + summary = row.summary(), + status = row.status(), ); methods(&mut out, &go, &row.sql_domain); if let Some((query, query_domain)) = &row.query { @@ -252,8 +230,10 @@ fn range_types(out: &mut String) { } } -/// Write `eql_gen.go` under `out_root` (the EQL subtree root, or the -/// override `EQL_CODEGEN_OUT_ROOT`). Returns the path written. +/// Write `eql_gen.go` under `out_root`, the EQL subtree root. The CLI +/// refuses an `EQL_CODEGEN_OUT_ROOT` override here: the path leaves the +/// subtree, so under an override it would land outside the throwaway tree. +/// Returns the path written. pub fn generate_go_eql(out_root: &Path) -> std::io::Result { let path = out_root.join(GO_EQL_PATH); if let Some(dir) = path.parent() { diff --git a/packages/eql/crates/eql-codegen/src/lib.rs b/packages/eql/crates/eql-codegen/src/lib.rs index 02137825b2..083961ec43 100644 --- a/packages/eql/crates/eql-codegen/src/lib.rs +++ b/packages/eql/crates/eql-codegen/src/lib.rs @@ -15,6 +15,7 @@ pub mod generate; pub mod go_eql; pub mod operator_surface; pub mod ordering; +pub mod py_eql; pub mod range; pub mod render; pub mod targets; diff --git a/packages/eql/crates/eql-codegen/src/main.rs b/packages/eql/crates/eql-codegen/src/main.rs index 5c473d4391..a2065a8966 100644 --- a/packages/eql/crates/eql-codegen/src/main.rs +++ b/packages/eql/crates/eql-codegen/src/main.rs @@ -14,6 +14,41 @@ fn out_root() -> PathBuf { } } +/// The root for `go-eql` and `py-eql`, whose output lives outside the EQL +/// subtree (`../../languages/...`). Those paths are relative to the subtree +/// root, so under an `EQL_CODEGEN_OUT_ROOT` override they would resolve +/// outside the throwaway tree, at its grandparent. The override is refused +/// for them rather than followed there. +fn cross_tree_root(command: &str) -> Result { + if std::env::var_os("EQL_CODEGEN_OUT_ROOT").is_some() { + return Err(format!( + "{command} writes outside the EQL subtree and cannot honour EQL_CODEGEN_OUT_ROOT; unset it" + )); + } + Ok(repo_root()) +} + +/// Run one cross-tree emitter at [`cross_tree_root`]. +fn cross_tree(command: &str, emit: fn(&std::path::Path) -> std::io::Result) -> ExitCode { + let root = match cross_tree_root(command) { + Ok(root) => root, + Err(e) => { + eprintln!("error: {e}"); + return ExitCode::from(2); + } + }; + match emit(&root) { + Ok(path) => { + println!("generated {}", path.display()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("error: {e}"); + ExitCode::FAILURE + } + } +} + fn main() -> ExitCode { let args: Vec = std::env::args().collect(); @@ -92,16 +127,15 @@ fn main() -> ExitCode { // the query types, and the Types table. Wired into `mise run // types:generate` beside `bindings`; `types:check` diffs the file. if args.len() == 2 && args[1] == "go-eql" { - match eql_codegen::go_eql::generate_go_eql(&out_root()) { - Ok(path) => { - println!("generated {}", path.display()); - return ExitCode::SUCCESS; - } - Err(e) => { - eprintln!("error: {e}"); - return ExitCode::FAILURE; - } - } + return cross_tree("go-eql", eql_codegen::go_eql::generate_go_eql); + } + + // `py-eql`: regenerate the committed Python module stackencrypt._eql in + // the Python workspace (languages/python) from the same catalog rows as + // `go-eql`: one marker class per EQL type, and the TYPES tuple. Wired + // into `mise run types:generate`; `types:check` diffs the file. + if args.len() == 2 && args[1] == "py-eql" { + return cross_tree("py-eql", eql_codegen::py_eql::generate_py_eql); } // `order`: print the install order of the whole src/v3 SQL surface, one @@ -259,5 +293,6 @@ fn main() -> ExitCode { eprintln!(" eql-codegen dump-catalog (print catalog surface as JSON)"); eprintln!(" eql-codegen bindings (regenerate eql-bindings Rust payload types)"); eprintln!(" eql-codegen go-eql (regenerate the Go package encrypt/eql)"); + eprintln!(" eql-codegen py-eql (regenerate the Python module stackencrypt._eql)"); ExitCode::from(2) } diff --git a/packages/eql/crates/eql-codegen/src/py_eql.rs b/packages/eql/crates/eql-codegen/src/py_eql.rs new file mode 100644 index 0000000000..069933e7c0 --- /dev/null +++ b/packages/eql/crates/eql-codegen/src/py_eql.rs @@ -0,0 +1,240 @@ +//! The Python `stackencrypt._eql` module emitter: renders `eql_domains::CATALOG` +//! to the committed `languages/python/python/stackencrypt/_eql.py` — one +//! marker class per EQL type, each carrying the catalog row's columns as +//! class attributes, and a `TYPES` tuple listing them in catalog order. The +//! spec (#1341, "Markers (generated)") fixes the ten attributes and puts the +//! emitter here, beside the Go one. +//! +//! The rows are [`crate::targets`]', the same the Go `Types` table and the +//! Rust `TARGETS` table are rendered from, so Python and Go cannot disagree +//! about a type; `tests/py_eql_parity.rs` compares the two outputs attribute +//! by attribute. A marker is named by the catalog name as it is (`TextEq`, +//! `Json`): Python has no initialism rule to apply. +//! +//! `__all__` lists the producible markers only. The package re-exports the +//! module with `from stackencrypt._eql import *`, so a marker the engine +//! cannot produce yet is in the module but not importable from the package. +//! +//! Generate-to-file like the Go module: `eql-codegen py-eql` writes the file, +//! `mise run types:check` diffs it, and `tests/py_eql_parity.rs` asserts the +//! committed file is what the catalog renders. + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +use crate::targets::{rows, Row}; + +/// The generated file, relative to the EQL subtree root (`repo_root()`): +/// the Python workspace lives beside `packages/` in the monorepo. +pub const PY_EQL_PATH: &str = "../../languages/python/python/stackencrypt/_eql.py"; + +/// The first line of the generated file, in the form Go uses and editors +/// recognise. +pub const PY_GENERATED_MARKER: &str = + "# Code generated by eql-codegen from the eql-domains catalog. DO NOT EDIT."; + +/// A Python string literal. Catalog text is printable, so only the quote +/// and the backslash need escaping; anything else is refused rather than +/// written in an escape Python would read differently. +fn py_str(s: &str) -> String { + assert!( + !s.chars().any(char::is_control), + "catalog text with a control character: {s:?}" + ); + format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) +} + +/// A Python string literal, or `None` for an absent value. +fn py_opt(s: Option<&str>) -> String { + s.map_or_else(|| "None".to_owned(), py_str) +} + +/// A Python tuple of strings: `()`, `("eq",)` or `("eq", "ore")`. +fn py_tuple(items: &[&str]) -> String { + match items { + [] => "()".to_owned(), + [one] => format!("({},)", py_str(one)), + _ => { + let quoted: Vec = items.iter().map(|s| py_str(s)).collect(); + format!("({})", quoted.join(", ")) + } + } +} + +/// The class docstring: the summary and the status line the Go emitter +/// writes too ([`Row::summary`], [`Row::status`]). +fn docstring(row: &Row) -> String { + py_docstring(&format!( + "The EQL type {summary}.\n\n {status}\n ", + summary = row.summary(), + status = row.status(), + )) +} + +/// `text` as a Python docstring literal. A backslash is doubled, so Python +/// reads it as written. Text with `"""` in it is refused: it would end the +/// docstring. +fn py_docstring(text: &str) -> String { + assert!(!text.contains("\"\"\""), "catalog text ends the docstring"); + format!("\"\"\"{}\"\"\"", text.replace('\\', "\\\\")) +} + +/// The names a catalog row may not take as its class name: Python keywords +/// that are names, and the module's own names, which a class of the same +/// name would replace. +const RESERVED_NAMES: [&str; 5] = ["Marker", "None", "True", "False", "TYPES"]; + +/// `name` as a Python class name. A catalog name is written straight into +/// `class {name}(Marker):` and into `TYPES`, so a name that is not a plain +/// class name, or that replaces one of the module's names, is refused here +/// rather than in the Python job. +fn py_ident(name: &str) -> &str { + assert!( + name.starts_with(|c: char| c.is_ascii_uppercase()) + && name.chars().all(|c| c.is_ascii_alphanumeric()) + && !RESERVED_NAMES.contains(&name), + "catalog name {name:?} is not a usable Python class name" + ); + name +} + +fn class(out: &mut String, row: &Row) { + let (query, query_sql_domain) = match &row.query { + Some((query, domain)) => (Some(query.as_str()), Some(domain.as_str())), + None => (None, None), + }; + let _ = write!( + out, + "\n\nclass {name}(Marker):\n {doc}\n\n \ + name = {name_lit}\n \ + family = {family}\n \ + suffix = {suffix}\n \ + plaintext = {plaintext}\n \ + sql_domain = {sql_domain}\n \ + indexes = {indexes}\n \ + query = {query}\n \ + query_sql_domain = {query_sql_domain}\n \ + producible = {producible}\n \ + reason = {reason}\n", + name = py_ident(&row.name), + doc = docstring(row), + name_lit = py_str(&row.name), + family = py_str(row.family), + suffix = py_str(&row.suffix), + plaintext = py_opt(row.plaintext), + sql_domain = py_str(&row.sql_domain), + indexes = py_tuple(&row.indexes), + query = py_opt(query), + query_sql_domain = py_opt(query_sql_domain), + producible = if row.reason.is_none() { + "True" + } else { + "False" + }, + reason = py_opt(row.reason), + ); +} + +/// Render the Python source of `_eql.py`. +pub fn render_py_eql() -> String { + let rows = rows(); + let mut out = String::new(); + let _ = writeln!( + out, + "{PY_GENERATED_MARKER}\n\ + \"\"\"The EQL types as stash markers: one class per catalog row, in catalog order.\n\ + \n\ + Generated from the same catalog rows as the Go package encrypt/eql. ``__all__``\n\ + lists the markers the engine produces today, which the package re-exports.\n\ + \"\"\"\n\ + \n\ + from stackencrypt._markers import Marker\n\ + \n\ + __all__ = [" + ); + for row in rows.iter().filter(|row| row.reason.is_none()) { + let _ = writeln!(out, " {},", py_str(&row.name)); + } + out.push_str("]\n"); + for row in &rows { + class(&mut out, row); + } + out.push_str("\n\nTYPES = (\n"); + for row in &rows { + let _ = writeln!(out, " {},", py_ident(&row.name)); + } + out.push_str(")\n"); + out +} + +/// Write `_eql.py` under `out_root` (the EQL subtree root). +pub fn generate_py_eql(out_root: &Path) -> std::io::Result { + let path = out_root.join(PY_EQL_PATH); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(&path, render_py_eql())?; + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strings_escape_the_quote_and_the_backslash() { + assert_eq!(py_str(r#"a "b" \c"#), r#""a \"b\" \\c""#); + } + + #[test] + #[should_panic(expected = "control character")] + fn a_string_with_a_control_character_is_refused() { + py_str("a\nb"); + } + + #[test] + fn an_absent_string_is_none() { + assert_eq!(py_opt(None), "None"); + assert_eq!(py_opt(Some("eq")), r#""eq""#); + } + + #[test] + fn a_docstring_doubles_a_backslash() { + assert_eq!(py_docstring(r"a \b"), r#""""a \\b""""#); + } + + #[test] + #[should_panic(expected = "ends the docstring")] + fn a_docstring_with_three_quotes_is_refused() { + py_docstring(r#"a """ b"#); + } + + #[test] + fn a_class_name_is_a_plain_capitalised_identifier() { + assert_eq!(py_ident("TextEq"), "TextEq"); + for bad in [ + "None", "True", "Marker", "TYPES", "textEq", "Text_Eq", "1Text", "", + ] { + assert!( + std::panic::catch_unwind(|| py_ident(bad)).is_err(), + "{bad:?} was accepted" + ); + } + } + + #[test] + fn tuples_have_python_syntax_for_every_length() { + assert_eq!(py_tuple(&[]), "()"); + assert_eq!(py_tuple(&["eq"]), r#"("eq",)"#); + assert_eq!(py_tuple(&["eq", "ore"]), r#"("eq", "ore")"#); + } + + #[test] + fn every_row_is_one_class_and_one_entry_in_types() { + let rendered = render_py_eql(); + let count = rows().len(); + assert_eq!(rendered.matches("(Marker):\n").count(), count); + let types = rendered.split("TYPES = (\n").nth(1).expect("a TYPES tuple"); + assert_eq!(types.lines().filter(|l| l.ends_with(',')).count(), count); + } +} diff --git a/packages/eql/crates/eql-codegen/src/targets.rs b/packages/eql/crates/eql-codegen/src/targets.rs index 56928b6d71..b01abef010 100644 --- a/packages/eql/crates/eql-codegen/src/targets.rs +++ b/packages/eql/crates/eql-codegen/src/targets.rs @@ -160,9 +160,10 @@ pub fn target_gap(family: &DomainFamily, domain: &Domain) -> Option<&'static str encryption_gap(TARGET_VERSION, family, domain) } -/// One row of the generated table, computed from the catalog: what both -/// the Rust `TARGETS` table and the Go `encrypt/eql` package -/// ([`crate::go_eql`]) are rendered from, so the two cannot disagree. +/// One row of the generated table, computed from the catalog: what the Rust +/// `TARGETS` table, the Go `encrypt/eql` package ([`crate::go_eql`]) and the +/// Python `stackencrypt._eql` module ([`crate::py_eql`]) are rendered from, +/// so the three cannot disagree. pub(crate) struct Row { pub(crate) name: String, pub(crate) family: &'static str, @@ -189,6 +190,45 @@ pub(crate) struct Row { pub(crate) document: bool, } +impl Row { + /// Where the type is stored and what it indexes, as the Go and Python + /// emitters both describe it: "stored in public.eql_v4_text_eq: the text + /// family with the eq index". One copy, so the two cannot word it apart. + pub(crate) fn summary(&self) -> String { + let indexes = if self.indexes.is_empty() { + "no index".to_owned() + } else { + format!( + "the {} index{}", + self.indexes.join(" and "), + if self.indexes.len() > 1 { "es" } else { "" } + ) + }; + format!( + "stored in {}: the {} family with {indexes}", + self.sql_domain, self.family + ) + } + + /// Whether the engine produces the type, as one sentence for the same + /// two emitters. + pub(crate) fn status(&self) -> String { + match (self.reason, self.plaintext) { + (None, Some(kind)) => { + // The kinds are read as words: "an int32", "a float64". + let article = if kind.starts_with(['a', 'e', 'i', 'o', 'u']) { + "an" + } else { + "a" + }; + format!("The engine produces it from {article} {kind}.") + } + (None, None) => "The engine produces it.".to_owned(), + (Some(reason), _) => format!("The engine cannot produce it yet: {reason}."), + } + } +} + /// The catalog domains that are targets, in catalog order: every stored /// scalar domain. The `json` family's SteVec document is not one: its SQL is /// EQL v3 only, and in v4 its domain name, `json_search`, is the JSON tree diff --git a/packages/eql/crates/eql-codegen/tests/cli.rs b/packages/eql/crates/eql-codegen/tests/cli.rs index c8b41ea8e3..a2f2ecd9b9 100644 --- a/packages/eql/crates/eql-codegen/tests/cli.rs +++ b/packages/eql/crates/eql-codegen/tests/cli.rs @@ -221,6 +221,41 @@ fn select_subcommand_drops_files_confined_to_the_other_version() { assert_eq!(out.status.code(), Some(2)); } +/// `go-eql` and `py-eql` write outside the EQL subtree, so an +/// `EQL_CODEGEN_OUT_ROOT` override would send them outside the throwaway tree. +/// Each refuses the override with exit 2, before writing anything. The +/// override is two folders deep, so the emitters' `../../` paths would land +/// inside the throwaway tree, where the test looks for them. +#[test] +fn cross_tree_emitters_refuse_the_output_root_override() { + let tree = tempdir(); + let out_root = tree.0.join("one").join("two"); + std::fs::create_dir_all(&out_root).expect("create the nested output root"); + for command in ["go-eql", "py-eql"] { + let out = Command::new(bin()) + .arg(command) + .env("EQL_CODEGEN_OUT_ROOT", out_root.as_os_str()) + .output() + .unwrap_or_else(|e| panic!("run eql-codegen {command}: {e}")); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(2), "{command}: {stderr}"); + assert!(out.stdout.is_empty(), "{command} printed a written path"); + assert!( + stderr.contains("EQL_CODEGEN_OUT_ROOT"), + "{command}: {stderr}" + ); + for path in [ + eql_codegen::go_eql::GO_EQL_PATH, + eql_codegen::py_eql::PY_EQL_PATH, + ] { + assert!( + !out_root.join(path).exists(), + "{command} wrote {path} before it refused" + ); + } + } +} + /// An unrecognised argument prints usage and exits 2 (the `ExitCode::from(2)` /// fall-through in `main.rs`). #[test] diff --git a/packages/eql/crates/eql-codegen/tests/py_eql_parity.rs b/packages/eql/crates/eql-codegen/tests/py_eql_parity.rs new file mode 100644 index 0000000000..2f7c71844c --- /dev/null +++ b/packages/eql/crates/eql-codegen/tests/py_eql_parity.rs @@ -0,0 +1,222 @@ +//! The Python `stackencrypt._eql` parity checks. +//! +//! The first is the twin of `go_eql_parity.rs`: the committed +//! `languages/python/python/stackencrypt/_eql.py` must be byte for byte what +//! the catalog renders. The second reads the two rendered outputs back and +//! compares every marker with the Go `Types` row of the same position, +//! attribute by attribute, so the two languages cannot disagree about a type +//! even if one emitter changes how it reads the catalog. + +use std::fs; + +use eql_codegen::go_eql::render_go_eql; +use eql_codegen::py_eql::{render_py_eql, PY_EQL_PATH}; +use eql_codegen::repo_root; + +#[test] +fn py_eql_matches_the_committed_file() { + let path = repo_root().join(PY_EQL_PATH); + let committed = fs::read_to_string(&path).unwrap_or_else(|e| { + panic!( + "committed Python module {} is missing or unreadable ({e}); run `mise run types:generate` and commit", + path.display() + ) + }); + assert_eq!( + render_py_eql(), + committed, + "{}: the committed Python module is stale or hand-edited — run `mise run types:generate` and commit the result", + path.display() + ); +} + +/// One attribute value, as both languages can express it. +#[derive(Debug, PartialEq)] +enum Value { + Str(Option), + List(Vec), + Bool(bool), +} + +/// The ten attributes, in the order both outputs write them, with the Go +/// field each one is. Go writes an absent value as `""`; Python as `None`. +const ATTRIBUTES: [(&str, &str); 10] = [ + ("name", "Name"), + ("family", "Family"), + ("suffix", "Suffix"), + ("plaintext", "Plaintext"), + ("sql_domain", "SQLDomain"), + ("indexes", "Indexes"), + ("query", "Query"), + ("query_sql_domain", "QuerySQLDomain"), + ("producible", "Producible"), + ("reason", "Reason"), +]; + +/// The attributes that are absent rather than empty when Go writes `""`. +const OPTIONAL: [&str; 4] = ["plaintext", "query", "query_sql_domain", "reason"]; + +/// Read one double-quoted string at the start of `s`, with the two escapes +/// both emitters write (`\"` and `\\`); return it and the rest of `s`. +fn quoted(s: &str) -> (String, &str) { + let mut chars = s + .strip_prefix('"') + .expect("a string literal") + .char_indices(); + let mut out = String::new(); + while let Some((at, c)) = chars.next() { + match c { + '"' => return (out, &s[at + 2..]), + '\\' => match chars.next() { + Some((_, e @ ('"' | '\\'))) => out.push(e), + other => panic!("an escape neither emitter writes: {other:?}"), + }, + c => out.push(c), + } + } + panic!("an unterminated string literal: {s}") +} + +/// The strings of a list literal whose opening bracket has been read, up to +/// `close`; return them and the rest after `close`. +fn strings(mut s: &str, close: char) -> (Vec, &str) { + let mut out = Vec::new(); + loop { + s = s.trim_start_matches([',', ' ']); + if let Some(rest) = s.strip_prefix(close) { + return (out, rest); + } + let (item, rest) = quoted(s); + out.push(item); + s = rest; + } +} + +/// Every row of the Go `Types` table, as (Go field, value) pairs. +fn go_rows() -> Vec> { + let go = render_go_eql(); + let table = go + .split("var Types = []Type{\n") + .nth(1) + .and_then(|rest| rest.split("\n}\n").next()) + .expect("the Go Types table"); + table + .lines() + .map(|line| { + let mut s = line + .trim() + .strip_prefix('{') + .and_then(|l| l.strip_suffix("},")) + .expect("one Go table row per line"); + let mut fields = Vec::new(); + while !s.is_empty() { + let (key, rest) = s.split_once(": ").expect("a Go field"); + let (value, rest) = if rest.starts_with('"') { + let (v, rest) = quoted(rest); + (Value::Str(Some(v)), rest) + } else if let Some(rest) = rest.strip_prefix("[]string{") { + let (v, rest) = strings(rest, '}'); + (Value::List(v), rest) + } else if let Some(rest) = rest.strip_prefix("nil") { + (Value::List(Vec::new()), rest) + } else if let Some(rest) = rest.strip_prefix("true") { + (Value::Bool(true), rest) + } else if let Some(rest) = rest.strip_prefix("false") { + (Value::Bool(false), rest) + } else { + panic!("a Go value neither emitter writes: {rest}") + }; + fields.push((key.to_owned(), value)); + s = rest.trim_start_matches(", "); + } + fields + }) + .collect() +} + +/// A class body after its docstring. The docstring quotes catalog text, so +/// a reason containing ` = ` would otherwise read as an attribute. +fn after_docstring(class: &str) -> &str { + let body = class + .strip_prefix(" \"\"\"") + .expect("every marker class opens with a docstring"); + let end = body.find("\"\"\"").expect("a closed docstring"); + &body[end + 3..] +} + +#[test] +fn a_docstring_line_with_an_equals_sign_is_not_an_attribute() { + let class = " \"\"\"The EQL type ...\n\n The engine cannot produce it yet: a = b.\n \"\"\"\n\n name = \"X\"\n"; + let attributes: Vec<&str> = after_docstring(class) + .lines() + .filter_map(|line| line.strip_prefix(" ")) + .filter_map(|line| line.split_once(" = ").map(|(key, _)| key)) + .collect(); + assert_eq!(attributes, ["name"]); +} + +/// Every marker class of the Python module, as (attribute, value) pairs. +fn py_rows() -> Vec> { + let py = render_py_eql(); + py.split("(Marker):\n") + .skip(1) + .map(|class| { + after_docstring(class) + .lines() + .filter_map(|line| line.strip_prefix(" ")) + .filter_map(|line| line.split_once(" = ")) + .map(|(key, raw)| { + let value = if raw.starts_with('"') { + Value::Str(Some(quoted(raw).0)) + } else if raw == "None" { + Value::Str(None) + } else if let Some(rest) = raw.strip_prefix('(') { + Value::List(strings(rest, ')').0) + } else if raw == "True" || raw == "False" { + Value::Bool(raw == "True") + } else { + panic!("a Python value the emitter does not write: {raw}") + }; + (key.to_owned(), value) + }) + .collect() + }) + .collect() +} + +#[test] +fn every_python_marker_matches_the_go_row_in_the_same_position() { + let go = go_rows(); + let py = py_rows(); + assert_eq!(py.len(), go.len(), "one marker per Go Types row"); + assert!(!py.is_empty(), "the catalog renders no rows"); + + for (py_row, go_row) in py.iter().zip(&go) { + let keys: Vec<&str> = py_row.iter().map(|(k, _)| k.as_str()).collect(); + let expected: Vec<&str> = ATTRIBUTES.iter().map(|(py, _)| *py).collect(); + assert_eq!( + keys, expected, + "a marker carries exactly the ten attributes" + ); + + for (py_key, go_key) in ATTRIBUTES { + let py_value = &py_row.iter().find(|(k, _)| k == py_key).unwrap().1; + let go_value = &go_row + .iter() + .find(|(k, _)| k == go_key) + .unwrap_or_else(|| panic!("a Go row without {go_key}")) + .1; + let go_value = match go_value { + Value::Str(Some(s)) if s.is_empty() && OPTIONAL.contains(&py_key) => { + &Value::Str(None) + } + other => other, + }; + assert_eq!( + py_value, go_value, + "{py_key} of {:?} differs from the Go row's {go_key}", + py_row[0].1 + ); + } + } +} diff --git a/packages/eql/mise.toml b/packages/eql/mise.toml index 79bea8ab09..029ca33a6c 100644 --- a/packages/eql/mise.toml +++ b/packages/eql/mise.toml @@ -388,15 +388,21 @@ tmp="$(mktemp -d)" # restores the snapshot so the working tree is left exactly as it was — never # fresh structs against stale committed TS/JSON that an automated commit could # capture. The snapshot is a filesystem copy (not `git checkout`) so a dev's -# legitimate uncommitted edits survive a failed run. +# legitimate uncommitted edits survive a failed run. The Go and Python files +# the same rows generate outside the subtree are snapshotted too, so a failed +# run cannot leave them regenerated against rolled-back bindings. v3="crates/eql-bindings/src/v3" v4="crates/eql-bindings/src/v4" bindings="crates/eql-bindings/bindings" schema="crates/eql-bindings/schema" +go_gen="../../languages/golang/encrypt/eql/eql_gen.go" +py_gen="../../languages/python/python/stackencrypt/_eql.py" cp -R "$v3" "$tmp/v3-snapshot" cp -R "$v4" "$tmp/v4-snapshot" cp -R "$bindings" "$tmp/bindings-snapshot" cp -R "$schema" "$tmp/schema-snapshot" +cp "$go_gen" "$tmp/go-gen-snapshot" +cp "$py_gen" "$tmp/py-gen-snapshot" ok=0 trap 'if [ "$ok" != 1 ]; then rm -rf "$v3" "$v4" "$bindings" "$schema" @@ -404,6 +410,8 @@ trap 'if [ "$ok" != 1 ]; then mv "$tmp/v4-snapshot" "$v4" mv "$tmp/bindings-snapshot" "$bindings" mv "$tmp/schema-snapshot" "$schema" + mv -f "$tmp/go-gen-snapshot" "$go_gen" + mv -f "$tmp/py-gen-snapshot" "$py_gen" fi rm -rf "$tmp"' EXIT # Default no-arg eql-codegen stays SQL-only; `bindings` is the Rust-only subcommand. @@ -411,6 +419,9 @@ cargo run -q -p eql-codegen -- bindings # The Go package encrypt/eql in the Go module, from the same catalog rows # as the Rust target table (crates/eql-codegen/src/go_eql.rs). cargo run -q -p eql-codegen -- go-eql +# The Python markers module stackencrypt._eql, from the same rows +# (crates/eql-codegen/src/py_eql.rs). +cargo run -q -p eql-codegen -- py-eql TS_RS_EXPORT_DIR="$tmp/bindings" EQL_TYPES_SCHEMA_DIR="$tmp/schema" cargo test -p eql-bindings rm -rf "$bindings" "$schema" mv "$tmp/bindings" "$bindings" @@ -429,12 +440,12 @@ set -euo pipefail # runs in contexts (test-eql.yml's Rust job, the pre-commit hook) that # provision mise tools but not pnpm/node_modules. node packages/eql/scripts/sync-generated.mjs -git diff --exit-code -- crates/eql-bindings/src/v3 crates/eql-bindings/src/v4 crates/eql-bindings/bindings crates/eql-bindings/schema packages/eql/src/generated ../../languages/golang/encrypt/eql/eql_gen.go || { - echo "eql-bindings generated Rust/TS/JSON, the Go package encrypt/eql or @cipherstash/eql package output is stale — run 'mise run types:generate' and 'mise run typescript:generate' and commit the result" >&2 +git diff --exit-code -- crates/eql-bindings/src/v3 crates/eql-bindings/src/v4 crates/eql-bindings/bindings crates/eql-bindings/schema packages/eql/src/generated ../../languages/golang/encrypt/eql/eql_gen.go ../../languages/python/python/stackencrypt/_eql.py || { + echo "eql-bindings generated Rust/TS/JSON, the Go package encrypt/eql, the Python module stackencrypt._eql or @cipherstash/eql package output is stale — run 'mise run types:generate' and 'mise run typescript:generate' and commit the result" >&2 exit 1 } # git diff is blind to brand-new files; untracked output is stale too. -untracked=$(git ls-files --others --exclude-standard -- crates/eql-bindings/src/v3 crates/eql-bindings/src/v4 crates/eql-bindings/bindings crates/eql-bindings/schema packages/eql/src/generated ../../languages/golang/encrypt/eql/eql_gen.go) +untracked=$(git ls-files --others --exclude-standard -- crates/eql-bindings/src/v3 crates/eql-bindings/src/v4 crates/eql-bindings/bindings crates/eql-bindings/schema packages/eql/src/generated ../../languages/golang/encrypt/eql/eql_gen.go ../../languages/python/python/stackencrypt/_eql.py) if [ -n "$untracked" ]; then echo "eql-bindings has uncommitted generated files:" >&2 echo "$untracked" >&2 diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 100cccf8a3..6aa1fb3393 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -824,17 +824,22 @@ pub struct FromEnv; /// workspace's `secretkey.json` in the profile directory. A profile directory /// that cannot be resolved is not an error here — env-only setups (CI) have /// none — it just leaves the environment as the only source. +/// +/// This is the provider [`StackCipherBuilder::init`] uses. It is public so +/// that a client built over its own connection, as the Python SDK builds one +/// to count its requests, finds the client key the same way. #[cfg(all(feature = "http", not(target_arch = "wasm32")))] -fn client_key_provider() -> FallbackKeyProvider { +pub fn client_key_provider() -> impl KeyProvider { FallbackKeyProvider::new( EnvKeyProvider, ProfileClientKey(ProfileStore::resolve(None).ok()), ) } -/// wasm32 has no filesystem, so no profile: the environment is the only source. +/// The client key from the environment: wasm32 has no filesystem, so no +/// profile, and the environment is the only source. #[cfg(all(feature = "http", target_arch = "wasm32"))] -fn client_key_provider() -> EnvKeyProvider { +pub fn client_key_provider() -> impl stack_kms::KeyProvider { EnvKeyProvider } diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 1f71f190d2..b4aa9dd8a3 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -603,6 +603,8 @@ pub mod target; pub mod testing; pub mod value; +#[cfg(feature = "http")] +pub use cipher::client_key_provider; pub use cipher::{ Error, FromEnv, LeafBytesError, PendingStackCipherText, SealedValue, StackCipher, StackCipherBuilder, StackCipherText, diff --git a/packages/stack-kms/src/builder.rs b/packages/stack-kms/src/builder.rs index 7d1bca2583..7a277a724d 100644 --- a/packages/stack-kms/src/builder.rs +++ b/packages/stack-kms/src/builder.rs @@ -227,7 +227,7 @@ impl StackKmsBuilder { fn build_opts(self) -> Result<(ClientOpts, C, S), StackKmsBuilderError> { let mut connection = self.connection; if connection.base_url().is_none() { - if let Some(endpoint) = Self::base_url_from_env()? { + if let Some(endpoint) = endpoint_from_env()? { connection = connection.with_base_url(endpoint); } } @@ -241,24 +241,34 @@ impl StackKmsBuilder { Ok((opts, self.credentials, self.client_key)) } +} - /// Resolve the ZeroKMS endpoint from the `CS_ZEROKMS_HOST` environment - /// variable (or legacy `CS_VITUR_HOST`). The first variable that is set - /// decides: an unusable value is an error, not a fall-through. - fn base_url_from_env() -> Result, StackKmsBuilderError> { - use crate::vars::CS_ZEROKMS_HOST; - - for env_var in CS_ZEROKMS_HOST { - if let Ok(value) = std::env::var(env_var) { - return value - .parse() - .map(Some) - .map_err(|source| StackKmsBuilderError::InvalidEndpoint { env_var, source }); - } +/// The ZeroKMS endpoint from the environment, as [`StackKmsBuilder`] reads +/// it: `CS_ZEROKMS_HOST`, or the legacy `CS_VITUR_HOST`. The first variable +/// that is set decides: an unusable value is an error, not a fall-through. +/// `None` when neither is set, which leaves the endpoint to the access +/// token's services claim. +/// +/// Public so that a client built over its own connection, as the Python +/// SDK builds one to count its requests, reads the endpoint the same way. +/// +/// # Errors +/// +/// [`StackKmsBuilderError::InvalidEndpoint`], naming the variable, when its +/// value is not a usable endpoint. +pub fn endpoint_from_env() -> Result, StackKmsBuilderError> { + use crate::vars::CS_ZEROKMS_HOST; + + for env_var in CS_ZEROKMS_HOST { + if let Ok(value) = std::env::var(env_var) { + return value + .parse() + .map(Some) + .map_err(|source| StackKmsBuilderError::InvalidEndpoint { env_var, source }); } - - Ok(None) } + + Ok(None) } impl StackKmsBuilder @@ -370,7 +380,7 @@ mod tests { const LEGACY: &str = "CS_VITUR_HOST"; fn from_env() -> Result, StackKmsBuilderError> { - StackKmsBuilder::::base_url_from_env() + endpoint_from_env() } #[test] diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs index ee484f39e1..30a28fdfb0 100644 --- a/packages/stack-kms/src/lib.rs +++ b/packages/stack-kms/src/lib.rs @@ -95,7 +95,7 @@ pub mod vars; // Builder (configures the default HTTP transport) #[cfg(feature = "http")] -pub use builder::{StackKmsBuilder, StackKmsBuilderError, WithKeyProvider}; +pub use builder::{endpoint_from_env, StackKmsBuilder, StackKmsBuilderError, WithKeyProvider}; // Clients pub use client::{ diff --git a/scripts/__tests__/cargo-lock-freshness.test.mjs b/scripts/__tests__/cargo-lock-freshness.test.mjs index ed7d965cd0..2083a1b668 100644 --- a/scripts/__tests__/cargo-lock-freshness.test.mjs +++ b/scripts/__tests__/cargo-lock-freshness.test.mjs @@ -170,26 +170,30 @@ describe('Cargo.lock records this tree’s crates at their real versions', () => // pushing it out of sync: `scripts/sync-lockstep-versions.mjs` writes its // `Cargo.toml` on every release. If this crate ever drops out of the pair // set, the check that matters most has silently stopped running. It is - // in the three locks whose workspaces build it: EQL's own, protect-ffi's, - // and the stack-encrypt Go guest's, whose `eql` build links it by path - // (ADR-0007, amended). The stack-* workspaces do not depend on it. The - // bump's own set is discovered by walking (`cargoLockWorkspaces`), so a - // lock added here is one it already rewrites; this pins that the walk - // still sees all three. + // in the four locks whose workspaces build it: EQL's own, protect-ffi's, + // the stack-encrypt Go guest's, whose `eql` build links it by path + // (ADR-0007, amended), and the Python SDK's, whose core crate links it by + // path for the EQL resolver (#1387). The stack-* workspaces do not depend + // on it. The bump's own set is discovered by walking + // (`cargoLockWorkspaces`), so a lock added here is one it already + // rewrites; this holds that the walk still sees all four. expect( PAIRS.filter(({ name }) => name === 'eql-bindings') .map(({ lock }) => lock) .sort(), ).toEqual([ 'languages/golang/encrypt/guest/Cargo.lock', + 'languages/python/Cargo.lock', + 'languages/python/crates/core/fuzz/Cargo.lock', 'languages/typescript/packages/protect-ffi/Cargo.lock', 'packages/eql/Cargo.lock', ]) }) - it('finds the root lock and the five detached stack-* locks', () => { - // The root workspace and the workspaces it excludes: the three cargo-fuzz - // crates and the two Go WASI guests, each with its own lock. + it('finds the root lock and the seven detached stack-* locks', () => { + // The root workspace and the workspaces it excludes: the three stack-* + // cargo-fuzz crates, the two Go WASI guests, the Python SDK and its core + // crate's cargo-fuzz crate, each with its own lock. expect(LOCKS).toEqual( expect.arrayContaining([ 'Cargo.lock', @@ -198,6 +202,8 @@ describe('Cargo.lock records this tree’s crates at their real versions', () => 'packages/stack-encrypt/fuzz/Cargo.lock', 'languages/golang/encrypt/guest/Cargo.lock', 'languages/golang/auth/guest/Cargo.lock', + 'languages/python/Cargo.lock', + 'languages/python/crates/core/fuzz/Cargo.lock', ]), ) }) diff --git a/scripts/__tests__/cargo-publish-opt-out.test.mjs b/scripts/__tests__/cargo-publish-opt-out.test.mjs index 6e1e465454..d21a05ec67 100644 --- a/scripts/__tests__/cargo-publish-opt-out.test.mjs +++ b/scripts/__tests__/cargo-publish-opt-out.test.mjs @@ -6,8 +6,8 @@ import { REPO_ROOT } from './lib/repo-root.mjs' /** * Every crate in every Cargo workspace must opt out of crates.io unless it is * deliberately allowlisted below: the root workspace (the stack-* crates), - * EQL, protect-ffi, and the single-package workspaces the root excludes (the - * three cargo-fuzz crates and the two Go WASI guests). + * EQL, protect-ffi, the Python SDK, and the single-package workspaces the root excludes (the + * four cargo-fuzz crates and the two Go WASI guests). * * A crate with no `publish` key is publishable BY DEFAULT, and release-plz * publishes every workspace member that has not opted out. So every member @@ -52,6 +52,14 @@ const WORKSPACES = [ publishable: new Set(), expects: 'crates/protect-ffi', }, + { + // The Python SDK's workspace. Nothing in it is published: the wheel + // maturin builds from `crates/engine` is the artefact, and it is not + // released either. + root: 'languages/python', + publishable: new Set(), + expects: 'crates/engine', + }, { // The root workspace. The stack-* crates' release-plz step publishes // exactly these five: stack-auth and stack-profile, and stack-kms, @@ -74,6 +82,7 @@ const WORKSPACES = [ 'packages/stack-auth/fuzz', 'packages/stack-kms/fuzz', 'packages/stack-encrypt/fuzz', + 'languages/python/crates/core/fuzz', 'languages/golang/encrypt/guest', 'languages/golang/auth/guest', ].map((root) => ({ root, publishable: new Set(), expects: '.' })), diff --git a/scripts/__tests__/cipherstash-client-parity.test.mjs b/scripts/__tests__/cipherstash-client-parity.test.mjs index 305ab4f65a..7bc90cf2ee 100644 --- a/scripts/__tests__/cipherstash-client-parity.test.mjs +++ b/scripts/__tests__/cipherstash-client-parity.test.mjs @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import { lockedPackages } from './lib/cargo-lock.mjs' import { REPO_ROOT } from './lib/repo-root.mjs' /** @@ -32,28 +33,6 @@ import { REPO_ROOT } from './lib/repo-root.mjs' const EQL_LOCK = 'packages/eql/Cargo.lock' const FFI_LOCK = 'languages/typescript/packages/protect-ffi/Cargo.lock' -/** - * Every `name` package a `Cargo.lock` records, as its version, source and - * checksum, sorted by version. The version alone is not enough: a git source - * or a `[patch.crates-io]` fork can keep the version number while resolving - * different code, and the source and checksum are what tell them apart. - */ -export function lockedPackages(source, name) { - const field = (block, key) => - new RegExp(`^${key} = "([^"]*)"$`, 'm').exec(block)?.[1] ?? null - return source - .split(/^\[\[package\]\]$/m) - .slice(1) - .filter((block) => new RegExp(`^name = "${name}"$`, 'm').test(block)) - .map((block) => ({ - version: field(block, 'version'), - source: field(block, 'source'), - checksum: field(block, 'checksum'), - })) - .filter((pkg) => pkg.version) - .sort((a, b) => a.version.localeCompare(b.version)) -} - const read = (file) => readFileSync(join(REPO_ROOT, file), 'utf8') describe('lockedPackages', () => { diff --git a/scripts/__tests__/claude-review-workflow.test.mjs b/scripts/__tests__/claude-review-workflow.test.mjs index 631a9be21d..7e88f78d73 100644 --- a/scripts/__tests__/claude-review-workflow.test.mjs +++ b/scripts/__tests__/claude-review-workflow.test.mjs @@ -1,13 +1,13 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' -import { readWorkflow } from './lib/workflows.mjs' +import { readWorkflow, triggers } from './lib/workflows.mjs' const WORKFLOW = '.github/workflows/claude-review.yml' const ACTION_SHA = 'bf38e86e58df9ebf3420326d019f955bb3be64dd' const gha = (expression) => `\${{ ${expression} }}` const workflow = readWorkflow(WORKFLOW) -const triggers = workflow.on ?? workflow[true] +const on = triggers(workflow) const review = workflow.jobs.review const claude = review.steps.find((step) => String(step.uses ?? '').startsWith('anthropics/claude-code-action@'), @@ -19,14 +19,14 @@ describe('Claude pull-request review', () => { }) it('reviews every agreed pull-request lifecycle event', () => { - expect(Object.keys(triggers)).toEqual(['pull_request']) - expect(triggers.pull_request.types).toEqual([ + expect(Object.keys(on)).toEqual(['pull_request']) + expect(on.pull_request.types).toEqual([ 'opened', 'synchronize', 'ready_for_review', 'reopened', ]) - expect(triggers.pull_request['paths-ignore']).toEqual([ + expect(on.pull_request['paths-ignore']).toEqual([ '.changeset/**', '**/__snapshots__/**', '**/*.snap', diff --git a/scripts/__tests__/code-review-workflow.test.mjs b/scripts/__tests__/code-review-workflow.test.mjs index 3b09436bec..49164e4382 100644 --- a/scripts/__tests__/code-review-workflow.test.mjs +++ b/scripts/__tests__/code-review-workflow.test.mjs @@ -11,13 +11,13 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { runsWhen } from './lib/expressions.mjs' -import { readWorkflow } from './lib/workflows.mjs' +import { readWorkflow, triggers } from './lib/workflows.mjs' const WORKFLOW = '.github/workflows/code-review.yml' const gha = (expression) => `\${{ ${expression} }}` const workflow = readWorkflow(WORKFLOW) -const triggers = workflow.on ?? workflow[true] +const on = triggers(workflow) const request = workflow.jobs.request const fire = request.steps.find( (step) => step.name === 'Fire the code-review routine', @@ -32,8 +32,8 @@ describe('Code-review routine trigger', () => { it('fires once per lifecycle event, never on every push', () => { // No `synchronize`: each push would start another review session. Mark the // pull request draft and ready again for a fresh review. - expect(Object.keys(triggers)).toEqual(['pull_request']) - expect(triggers.pull_request.types).toEqual([ + expect(Object.keys(on)).toEqual(['pull_request']) + expect(on.pull_request.types).toEqual([ 'opened', 'reopened', 'ready_for_review', diff --git a/scripts/__tests__/crates-ci.test.mjs b/scripts/__tests__/crates-ci.test.mjs index 72c5046127..5596184a62 100644 --- a/scripts/__tests__/crates-ci.test.mjs +++ b/scripts/__tests__/crates-ci.test.mjs @@ -3,7 +3,12 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { REPO_ROOT } from './lib/repo-root.mjs' -import { readWorkflow, WORKFLOW_DIR, workflowFiles } from './lib/workflows.mjs' +import { + readWorkflow, + triggers, + WORKFLOW_DIR, + workflowFiles, +} from './lib/workflows.mjs' /** * Guards that the stack-* crates, their node bindings and the Go module are @@ -411,8 +416,6 @@ describe('the imported workflow directory is gone', () => { // The silent failures specific to this set // --------------------------------------------------------------------------- -const triggers = (wf) => wf?.on ?? wf?.[true] ?? {} - /** Every step of every root workflow, with its job and effective env. */ const STEPS = ROOT_WORKFLOWS.flatMap(({ relPath, wf }) => Object.entries(wf?.jobs ?? {}).flatMap(([jobName, job]) => diff --git a/scripts/__tests__/dependabot-cargo-cross-workspace.test.mjs b/scripts/__tests__/dependabot-cargo-cross-workspace.test.mjs index 81ddf6f4ab..b1ecd6274d 100644 --- a/scripts/__tests__/dependabot-cargo-cross-workspace.test.mjs +++ b/scripts/__tests__/dependabot-cargo-cross-workspace.test.mjs @@ -88,7 +88,12 @@ function crossWorkspaceEdges(workspaces) { } const edges = [] for (const [dir, crates] of workspaces) { - for (const crate of crates) { + // The workspace root manifest is scanned too: a `path =` under its + // `[workspace.dependencies]` is a dependency of every member that + // inherits it with `workspace = true`, and the member's own manifest + // then names no path. A single-crate workspace's root is already in + // `crates`, so the Set keeps it from being read twice. + for (const crate of new Set([dir, ...crates])) { for (const to of pathDependencies(crate, readManifest(crate))) { const toDir = owner.get(to) ?? null const seen = edges.some((e) => e.from === crate && e.to === to) @@ -129,6 +134,17 @@ describe('cargo Dependabot entries that share crates across workspaces never edi expect(edges).not.toHaveLength(0) }) + it('reads a path dependency declared in [workspace.dependencies]', () => { + // languages/python names stack-encrypt only in its root manifest; its + // crates inherit it. The guard missed that edge until this test existed. + expect(edges).toContainEqual({ + from: 'languages/python', + to: 'packages/stack-encrypt', + fromDir: 'languages/python', + toDir: '', + }) + }) + it('covers every crate a path dependency reaches', () => { const uncovered = edges.filter((e) => e.toDir === null) expect( diff --git a/scripts/__tests__/eql-suite-ci.test.mjs b/scripts/__tests__/eql-suite-ci.test.mjs index bcd8ceebec..83da8748e8 100644 --- a/scripts/__tests__/eql-suite-ci.test.mjs +++ b/scripts/__tests__/eql-suite-ci.test.mjs @@ -5,7 +5,12 @@ import yaml from 'js-yaml' import { describe, expect, it } from 'vitest' import { REPO_ROOT } from './lib/repo-root.mjs' import { includes } from './lib/rust-compiled-files.mjs' -import { readWorkflow, WORKFLOW_DIR, workflowFiles } from './lib/workflows.mjs' +import { + readWorkflow, + runWorkingDirectory, + WORKFLOW_DIR, + workflowFiles, +} from './lib/workflows.mjs' /** * Guards that the EQL suite is RUN, not merely present. @@ -69,21 +74,37 @@ const SUITE_TASKS = [ ] /** - * The part of a workflow that actually does something: `jobs:` and everything - * under it, with comment lines stripped. + * The part of a workflow that runs an EQL task: the `run:` bodies of the steps + * that execute inside `packages/eql`, with shell comment lines stripped. * - * Lifted from `integrationSuiteCi.test.ts`, and for the reason recorded there — - * a claim satisfied by prose rather than by a step. Every assertion below is a - * substring search, and this file's own header names all three suite tasks. Cut - * at `jobs:` and the `on:`/`paths:` block goes too, which is what makes "a - * `paths:` filter naming the suite is a trigger, not a run" true rather than - * merely intended. + * Only `run:` bodies, so a claim is satisfied by a step and not by prose. + * That is the reason `integrationSuiteCi.test.ts` cuts at `jobs:`. This + * file's own header names all three suite tasks. So does a `paths:` filter, + * and a filter is a trigger, not a run. + * + * Only the steps whose working directory is under `packages/eql`. A mise + * task name is scoped to the config mise finds from the current directory, + * and to nothing else. `mise run test` from `languages/python` runs THAT + * workspace's `test` task. Read without the directory, it counted as EQL's + * `test`, and EQL's `test` depends on `test:sqlx` and `test:lint`. So the + * two exemptions below were wrong from the first commit of + * `tests-python.yml`. They were a false "CI runs this", the direction this + * file exists to rule out. The directory is resolved by `runWorkingDirectory`. + * AGENTS.md records that every EQL task must run that way, and every EQL + * workflow does. A composite action's bodies are appended by the caller + * unscoped, as before. An action is reached by `uses:`, and `uses:` has no + * working directory of its own. */ -function executablePart(body) { - const jobsAt = body.search(/^jobs:/m) - // No `jobs:` key means nothing runs, so nothing can match. Returning the - // whole body here would hand every assertion the header comments instead. - return jobsAt === -1 ? '' : body.slice(jobsAt).replace(/^[ \t]*#.*$/gm, '') +const EQL_DIR = EQL_PREFIX.slice(0, -1) +const insideEql = (dir) => + dir !== undefined && + (String(dir) === EQL_DIR || String(dir).startsWith(EQL_PREFIX)) +function eqlRunBodies(wf) { + return Object.values(wf?.jobs ?? {}).flatMap((job) => + (Array.isArray(job?.steps) ? job.steps : []) + .filter((step) => insideEql(runWorkingDirectory(wf, job, step))) + .map((step) => String(step?.run ?? '').replace(/^[ \t]*#.*$/gm, '')), + ) } /** The two spellings GitHub accepts for a composite action's manifest. */ @@ -158,18 +179,91 @@ const workflowSteps = (wf) => * makes the mutation test below report a workflow as its own sole caller. */ const rootWorkflows = workflowFiles().map((relPath) => { - const actions = workflowSteps(readWorkflow(relPath)) + const wf = readWorkflow(relPath) + const actions = workflowSteps(wf) .map((step) => String(step?.uses ?? '')) .map((uses) => actionRunBodies(uses)) return { relPath, - body: [ - executablePart(readFileSync(join(REPO_ROOT, relPath), 'utf8')), - ...actions, - ].join('\n'), + body: [...eqlRunBodies(wf), ...actions].join('\n'), } }) +describe('a `mise run` counts as an EQL task only from inside packages/eql', () => { + const step = (run, dir) => + dir === undefined ? { run } : { run, 'working-directory': dir } + const workflow = (jobs, defaults) => ({ defaults, jobs }) + + it('reads the step, the job default and the workflow default, in that order', () => { + const wf = workflow( + { + own: { steps: [step('mise run test:schema', 'packages/eql')] }, + job: { + defaults: { run: { 'working-directory': 'packages/eql' } }, + steps: [step('mise run test:crates')], + }, + nested: { + steps: [step('mise run build', 'packages/eql/packages/eql')], + }, + overridden: { + defaults: { run: { 'working-directory': 'packages/eql' } }, + steps: [step('mise run test:cargo', 'languages/python')], + }, + }, + { run: { 'working-directory': 'packages/eql' } }, + ) + expect(eqlRunBodies(wf)).toEqual([ + 'mise run test:schema', + 'mise run test:crates', + 'mise run build', + ]) + }) + + it("does not read another workspace's task of the same name as EQL's", () => { + // tests-python.yml's `python` job: `mise run test` and `mise run + // test:cargo` under languages/python. EQL has a `test` task too, and it + // depends on `test:sqlx` and `test:lint`; counted here, both read as run + // by CI and their exemptions below as stale. + const wf = workflow({ + python: { + defaults: { run: { 'working-directory': 'languages/python' } }, + steps: [step('mise run test:cargo'), step('mise run test')], + }, + root: { steps: [step('mise run test:doc')] }, + }) + expect(eqlRunBodies(wf)).toEqual([]) + }) + + it('does not read a sibling folder with the same prefix as packages/eql', () => { + const wf = workflow({ + sibling: { steps: [step('mise run test:sqlx', 'packages/eql-bench')] }, + slash: { steps: [step('mise run test:schema', 'packages/eql/')] }, + }) + expect(eqlRunBodies(wf)).toEqual(['mise run test:schema']) + }) + + it('strips shell comment lines, so a mention in a comment is not a run', () => { + const wf = workflow({ + eql: { + steps: [ + step( + '# run mise run test:sqlx first\nmise run test:schema', + 'packages/eql', + ), + ], + }, + }) + expect(eqlRunBodies(wf)).toEqual(['\nmise run test:schema']) + }) + + it('finds a real step this way, so the scan is not fixture-only', () => { + const real = rootWorkflows.find( + ({ relPath }) => relPath === `${WORKFLOW_DIR}/test-eql.yml`, + ) + expect(real?.body).toContain('test:sqlx:partition') + }) +}) + describe('a root workflow runs the EQL SQLx suite', () => { it('finds workflows at the repository root at all', () => { // Guards the guard. An empty scan satisfies every "some workflow contains @@ -505,8 +599,42 @@ const RUST_CACHE_STEPS = workflowFiles().flatMap((relPath) => { ) }) -/** The rust-cache steps in EQL's jobs. */ -const EQL_RUST_CACHE_STEPS = RUST_CACHE_STEPS.filter(({ eql }) => eql) +/** + * The workspaces a rust-cache step names: one per line of `workspaces:`, as + * rust-cache reads it. A job that builds EQL and another workspace + * (tests-python.yml's `live` builds the Python wheel and the EQL bundle) + * lists both in one step. Two steps would each prune the shared cargo + * registry to their own crates before they save. + */ +const workspacesOf = (step) => + String(step?.with?.workspaces ?? '') + .split('\n') + // rust-cache's `dir -> target` form: the workspace is the part before. + .map((line) => line.split('->')[0].trim()) + .filter(Boolean) + +/** + * Whether `dir` is the root of another Cargo workspace: a directory outside + * the EQL tree with its own `Cargo.lock`. A member of the EQL workspace, or + * the monorepo root, is not another workspace, so the check below still + * catches a step that names either one instead of the EQL root. + */ +const isAnotherWorkspace = (dir) => + dir !== '.' && + dir !== CARGO_WORKSPACE && + !dir.startsWith(`${CARGO_WORKSPACE}/`) && + existsSync(join(REPO_ROOT, dir, 'Cargo.lock')) + +/** A step that caches other workspaces only, and not EQL's. */ +const namesAnotherWorkspace = ({ step }) => { + const dirs = workspacesOf(step) + return dirs.length > 0 && dirs.every(isAnotherWorkspace) +} + +/** The rust-cache steps in EQL's jobs that cache the EQL workspace. */ +const EQL_RUST_CACHE_STEPS = RUST_CACHE_STEPS.filter( + (entry) => entry.eql && !namesAnotherWorkspace(entry), +) const stepLabel = ({ relPath, jobName }, detail) => `${relPath} (${jobName}): ${detail}` @@ -548,13 +676,21 @@ describe('the shared Rust cache is pointed and saved correctly', () => { // root workspace, and rust-cache's default (`. -> target`) is right there. // Held non-empty, so a change to `isEqlJob` cannot empty the check. expect(EQL_RUST_CACHE_STEPS.length).toBeGreaterThan(0) - const wrong = EQL_RUST_CACHE_STEPS.filter( - ({ step }) => - !String(step?.with?.workspaces ?? '') - .split('\n') - .map((line) => line.split('->')[0].trim()) - .includes(CARGO_WORKSPACE), - ).map((entry) => + // The step names the EQL root, and any other entry is another + // workspace's root, never an EQL member. The monorepo root is the root + // Cargo workspace: it may be named beside the EQL root, as by + // tests-golang.yml's wasi-check, which builds both, but never instead + // of it. + const wrong = EQL_RUST_CACHE_STEPS.filter(({ step }) => { + const dirs = workspacesOf(step) + return ( + !dirs.includes(CARGO_WORKSPACE) || + !dirs.every( + (dir) => + dir === CARGO_WORKSPACE || dir === '.' || isAnotherWorkspace(dir), + ) + ) + }).map((entry) => stepLabel(entry, `workspaces: ${entry.step?.with?.workspaces}`), ) expect( diff --git a/scripts/__tests__/eql-workflow-filters.test.mjs b/scripts/__tests__/eql-workflow-filters.test.mjs index 5f230772d7..c47b242f6b 100644 --- a/scripts/__tests__/eql-workflow-filters.test.mjs +++ b/scripts/__tests__/eql-workflow-filters.test.mjs @@ -4,7 +4,7 @@ import { join } from 'node:path' import yaml from 'js-yaml' import { describe, expect, it } from 'vitest' import { REPO_ROOT } from './lib/repo-root.mjs' -import { readWorkflow } from './lib/workflows.mjs' +import { readWorkflow, triggers } from './lib/workflows.mjs' /** * The two EQL workflows decide what to run from a list of path globs, and both @@ -59,12 +59,6 @@ import { readWorkflow } from './lib/workflows.mjs' const TEST_EQL = '.github/workflows/test-eql.yml' const BENCH_EQL = '.github/workflows/bench-eql.yml' -/** `on:` parses as the boolean `true` under YAML 1.1 — the "Norway problem". */ -function triggers(wf) { - const on = wf?.on ?? wf?.[true] - return on && typeof on === 'object' ? on : {} -} - /** A workflow's `on.push.paths` list, or `[]`. */ function pushPaths(relPath) { const paths = triggers(readWorkflow(relPath))?.push?.paths diff --git a/scripts/__tests__/ffi-binding-step-order.test.mjs b/scripts/__tests__/ffi-binding-step-order.test.mjs index 517ccf9a15..4064114008 100644 --- a/scripts/__tests__/ffi-binding-step-order.test.mjs +++ b/scripts/__tests__/ffi-binding-step-order.test.mjs @@ -229,6 +229,10 @@ const BINDING_EXEMPT_JOBS = new Map([ '.github/workflows/test-eql.yml / e2e', 'Rust: the proptest-e2e oracle encrypts through cipherstash-client, and never loads index.node or dist/wasm.', ], + [ + '.github/workflows/tests-python.yml / live', + 'Rust and Python: the Python live tests encrypt through the stackencrypt extension, built from languages/python by maturin, which links stack-encrypt directly. It never loads index.node or dist/wasm.', + ], [ '.github/workflows/tests-golang.yml / live', 'Go and Rust: the Go live tests encrypt through the stack-encrypt WASI guest, and the stack-encrypt examples link the crate directly. Neither loads index.node or dist/wasm.', diff --git a/scripts/__tests__/fuzz-workflow-paths.test.mjs b/scripts/__tests__/fuzz-workflow-paths.test.mjs new file mode 100644 index 0000000000..af45e7ac82 --- /dev/null +++ b/scripts/__tests__/fuzz-workflow-paths.test.mjs @@ -0,0 +1,160 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { pathPackageNames } from './lib/cargo-lock.mjs' +import { filterCovers, negationRemovesAll } from './lib/paths-filter.mjs' +import { REPO_ROOT } from './lib/repo-root.mjs' +import { + compiledCrateFiles, + includes, + moduleDeclarations, +} from './lib/rust-compiled-files.mjs' +import { readWorkflow, triggers } from './lib/workflows.mjs' + +/** + * `fuzz.yml`'s `pull_request` filter is held to the crates its fuzz targets + * compile, read from each fuzz crate's own `Cargo.lock`. + * + * The regression job is the only thing that builds a harness on a pull + * request. A change to a crate a harness compiles, and the filter does not + * name, can break the harness with no job to notice. Each fuzz crate is a + * detached workspace, so its `Cargo.lock` lists every crate it compiles from + * this tree as a package with no `source`. A new path dependency appears + * there, and this file fails until the filter follows. + * + * The fuzz crates are the campaign matrix's `dir` values, each with `/fuzz`. + * The files each crate compiles come from `compiledCrateFiles`, which + * follows `mod`, `#[path]` and `include_str!` with comments stripped, with + * every feature counted as on. `python-ci.test.mjs` holds `tests-python.yml` + * to the Python workspace the same way. + */ + +const WORKFLOW = '.github/workflows/fuzz.yml' + +const read = (rel) => readFileSync(join(REPO_ROOT, rel), 'utf8') + +/** The fuzz crates, from the campaign matrix. */ +function fuzzCrates() { + const job = readWorkflow(WORKFLOW).jobs['fuzz-campaign'] + return [ + ...new Set(job.strategy.matrix.include.map((row) => `${row.dir}/fuzz`)), + ].sort() +} + +/** Where a path package of this tree lives, checked against its manifest. */ +function crateDir(name) { + const candidates = [ + `packages/${name}`, + `packages/eql/crates/${name}`, + `languages/python/crates/${name.replace(/^stackencrypt-/, '')}`, + ] + const dir = candidates.find((d) => { + const manifest = join(REPO_ROOT, d, 'Cargo.toml') + return ( + existsSync(manifest) && + new RegExp(`^name = "${name}"$`, 'm').test(read(`${d}/Cargo.toml`)) + ) + }) + if (!dir) { + throw new Error( + `a fuzz Cargo.lock lists ${name} as a path dependency, and none of ${candidates.join(', ')} holds its Cargo.toml; teach this test where it lives`, + ) + } + return dir +} + +/** The directories of the path packages a fuzz crate compiles. */ +function pathDependencyTrees(fuzzCrate) { + return pathPackageNames(read(`${fuzzCrate}/Cargo.lock`)) + .filter((name) => !name.endsWith('-fuzz')) + .map(crateDir) +} + +/** Every feature counted as on: a module behind any feature is kept. */ +const EVERY_FEATURE = { has: () => true } + +/** The files a path package compiles, `#[path]` and include files among them. */ +function compiledFiles(crate) { + const { files, unresolved } = compiledCrateFiles( + REPO_ROOT, + crate, + EVERY_FEATURE, + ) + if (unresolved.length > 0) { + throw new Error( + `${crate}: compiledCrateFiles could not follow:\n${unresolved.join('\n')}`, + ) + } + return files +} + +/** + * The files a fuzz crate's targets name by `#[path]` or an include. A target + * is a crate root that `compiledCrateFiles` does not walk, since it walks a + * library. Its plain `mod` files sit in the fuzz crate's own folder. + */ +function fuzzTargetFiles(fuzzCrate) { + const dir = `${fuzzCrate}/fuzz_targets` + const rel = (abs) => relative(REPO_ROOT, abs).replaceAll('\\', '/') + return readdirSync(join(REPO_ROOT, dir)) + .filter((name) => name.endsWith('.rs')) + .flatMap((name) => { + const file = join(REPO_ROOT, dir, name) + const text = readFileSync(file, 'utf8') + return [ + rel(file), + ...moduleDeclarations(text) + .filter((decl) => decl.path !== undefined) + .map((decl) => rel(resolve(dirname(file), decl.path))), + ...includes(text) + .filter((include) => include.literal !== undefined) + .map((include) => rel(resolve(dirname(file), include.literal))), + ] + }) +} + +const coversTree = (pattern, file) => + pattern.endsWith('/**') && file.startsWith(pattern.slice(0, -2)) + +const covers = (pattern, file) => pattern === file || coversTree(pattern, file) + +describe('fuzz.yml', () => { + const paths = triggers(readWorkflow(WORKFLOW)).pull_request?.paths ?? [] + // `!**.md` and `!**.example` drop documents only, not a whole tree. + const selected = (file) => + filterCovers(paths, file, covers, negationRemovesAll) + + it('finds every fuzz crate, the Python one included', () => { + expect(fuzzCrates()).toEqual([ + 'languages/python/crates/core/fuzz', + 'packages/stack-auth/fuzz', + 'packages/stack-encrypt/fuzz', + 'packages/stack-kms/fuzz', + ]) + }) + + it('runs on pull requests touching every crate a fuzz target compiles', () => { + const trees = [...new Set(fuzzCrates().flatMap(pathDependencyTrees))] + expect(trees).toContain('packages/eql/crates/eql-bindings') + const missing = trees.filter((dir) => !selected(`${dir}/Cargo.toml`)) + expect( + missing, + `pull_request.paths does not select these crates, which a fuzz target compiles:\n${missing.join('\n')}`, + ).toEqual([]) + }) + + it('runs on pull requests touching every file those crates and the targets compile', () => { + const trees = [...new Set(fuzzCrates().flatMap(pathDependencyTrees))] + const files = [ + ...trees.flatMap(compiledFiles), + ...fuzzCrates().flatMap(fuzzTargetFiles), + ] + // The Go guest's resolver, which the Python core crate compiles by #[path]. + expect(files).toContain('languages/golang/encrypt/guest/src/targets.rs') + const missing = [...new Set(files.filter((file) => !selected(file)))] + expect( + missing, + `pull_request.paths does not select these files, which a fuzz target compiles:\n${missing.join('\n')}`, + ).toEqual([]) + }) +}) diff --git a/scripts/__tests__/lib/cargo-lock.mjs b/scripts/__tests__/lib/cargo-lock.mjs new file mode 100644 index 0000000000..fcce47d160 --- /dev/null +++ b/scripts/__tests__/lib/cargo-lock.mjs @@ -0,0 +1,39 @@ +/** + * The packages a `Cargo.lock` records with no `source` line: the crates its + * workspace compiles from this tree by path, the workspace's own members + * among them. A new path dependency appears here, so a test that derives a + * `paths:` filter or a cache key from this list fails until it follows. + */ +export function pathPackageNames(lock) { + return lock + .split('[[package]]') + .slice(1) + .filter((block) => !/^source = /m.test(block)) + .map((block) => { + const name = /^name = "([^"]+)"/m.exec(block)?.[1] + if (!name) throw new Error(`a Cargo.lock package with no name:\n${block}`) + return name + }) +} + +/** + * Every `name` package a `Cargo.lock` records, as its version, source and + * checksum, sorted by version. The version alone is not enough: a git source + * or a `[patch.crates-io]` fork can keep the version number while resolving + * different code, and the source and checksum are what tell them apart. + */ +export function lockedPackages(source, name) { + const field = (block, key) => + new RegExp(`^${key} = "([^"]*)"$`, 'm').exec(block)?.[1] ?? null + return source + .split(/^\[\[package\]\]$/m) + .slice(1) + .filter((block) => new RegExp(`^name = "${name}"$`, 'm').test(block)) + .map((block) => ({ + version: field(block, 'version'), + source: field(block, 'source'), + checksum: field(block, 'checksum'), + })) + .filter((pkg) => pkg.version) + .sort((a, b) => a.version.localeCompare(b.version)) +} diff --git a/scripts/__tests__/lib/workflows.mjs b/scripts/__tests__/lib/workflows.mjs index e8f933176a..e162d5ef17 100644 --- a/scripts/__tests__/lib/workflows.mjs +++ b/scripts/__tests__/lib/workflows.mjs @@ -48,8 +48,36 @@ export function workflowFiles() { * both kinds with the same reader. * * Note for callers: `on:` parses as the boolean `true` under YAML 1.1 (the - * "Norway problem"), so read triggers as `wf.on ?? wf[true]`. + * "Norway problem"), so read triggers with `triggers(wf)` below. */ export function readWorkflow(relPath) { return yaml.load(readFileSync(join(REPO_ROOT, relPath), 'utf8')) } + +/** + * The triggers of a parsed workflow, as an object. `on:` parses as the boolean + * `true` under YAML 1.1 (the "Norway problem"), so the key is `true` unless + * the file quoted it. A workflow with no usable `on:` gives `{}`, so a caller + * can read `.pull_request` or test `'workflow_dispatch' in` without a guard. + * `workflow_dispatch:` with no value parses as null, so membership, not + * truthiness, is what says a trigger is declared. Before this helper, the + * guards wrote their own `wf.on ?? wf[true]`. + */ +export function triggers(wf) { + const on = wf?.on ?? wf?.[true] + return on && typeof on === 'object' ? on : {} +} + +/** + * The directory a step's `run:` executes in. GitHub takes the step's own + * `working-directory`, else the job's `defaults.run.working-directory`, else + * the workflow's. The result is `undefined` when none is set, and GitHub then + * runs the step at the checkout root. + */ +export function runWorkingDirectory(wf, job, step) { + return ( + step?.['working-directory'] ?? + job?.defaults?.run?.['working-directory'] ?? + wf?.defaults?.run?.['working-directory'] + ) +} diff --git a/scripts/__tests__/lint-release-scope.test.mjs b/scripts/__tests__/lint-release-scope.test.mjs index fa9f671428..32b3330fc4 100644 --- a/scripts/__tests__/lint-release-scope.test.mjs +++ b/scripts/__tests__/lint-release-scope.test.mjs @@ -2,7 +2,12 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { REPO_ROOT } from './lib/repo-root.mjs' -import { readWorkflow, WORKFLOW_DIR, workflowFiles } from './lib/workflows.mjs' +import { + readWorkflow, + triggers, + WORKFLOW_DIR, + workflowFiles, +} from './lib/workflows.mjs' /** * `lint-release.yml` states its scope TWICE — once as the `pull_request` paths @@ -23,10 +28,7 @@ import { readWorkflow, WORKFLOW_DIR, workflowFiles } from './lib/workflows.mjs' const LINT_RELEASE = `${WORKFLOW_DIR}/lint-release.yml` const workflow = readWorkflow(LINT_RELEASE) -// `on:` parses as the boolean `true` under YAML 1.1 — the Norway problem. -const triggers = workflow.on ?? workflow[true] - -const filtered = triggers.pull_request.paths.filter((path) => +const filtered = triggers(workflow).pull_request.paths.filter((path) => path.startsWith(`${WORKFLOW_DIR}/`), ) diff --git a/scripts/__tests__/python-ci.test.mjs b/scripts/__tests__/python-ci.test.mjs new file mode 100644 index 0000000000..b051bd2940 --- /dev/null +++ b/scripts/__tests__/python-ci.test.mjs @@ -0,0 +1,451 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { join, posix } from 'node:path' +import { describe, expect, it } from 'vitest' +import { pathPackageNames } from './lib/cargo-lock.mjs' +import { filterCovers } from './lib/paths-filter.mjs' +import { REPO_ROOT } from './lib/repo-root.mjs' +import { + readWorkflow, + runWorkingDirectory, + triggers, +} from './lib/workflows.mjs' + +/** + * `tests-python.yml` is held to the Python workspace it tests, with every + * expectation derived from the tree rather than copied into this file. + * + * The workspace at `languages/python` is the fourth Cargo workspace. It is + * the first whose checks a path filter can skip without anything else + * noticing. `tests-crates.yml` compiles the stack-* crates but never links + * them into pyo3. `tests.yml`'s `changes` filter excludes those crates on + * purpose. So a stack-encrypt change that breaks the Python engine crate is + * caught here or nowhere. "Here" depends on the `pull_request` filter naming + * every tree the workspace compiles. That list is the workspace's path + * dependencies. + * `Cargo.lock` records those as packages with no `source`. A new path + * dependency appears in the lockfile, and this file fails until the filter + * follows. + * + * The other facts are read the same way. The two Python versions come from + * `pyproject.toml`'s floor and `mise.toml`'s own version. The six wheel + * targets come from the protect-ffi platform packages. The issue names "the + * six platforms the FFI already ships for". A seventh platform or a moved + * floor changes the source and this file says so. + */ + +const WORKFLOW = '.github/workflows/tests-python.yml' +const PYTHON_ROOT = 'languages/python' + +const read = (rel) => readFileSync(join(REPO_ROOT, rel), 'utf8') + +/** + * The directories outside `languages/python` whose crates the workspace + * compiles. Every `[[package]]` in its Cargo.lock without a `source` line is + * a path dependency. The stack-* ones live at `packages/`. The EQL + * crates live at `packages/eql/crates/`. + */ +function pathDependencyTrees() { + const trees = new Set() + for (const name of pathPackageNames(read(`${PYTHON_ROOT}/Cargo.lock`))) { + if (name.startsWith('stackencrypt-')) continue + const dir = [`packages/${name}`, `packages/eql/crates/${name}`].find((d) => + existsSync(join(REPO_ROOT, d, 'Cargo.toml')), + ) + if (!dir) { + throw new Error( + `${PYTHON_ROOT}/Cargo.lock lists ${name} as a path dependency, and neither packages/${name} nor packages/eql/crates/${name} holds its Cargo.toml; teach this test where it lives`, + ) + } + trees.add(dir) + } + return [...trees].sort() +} + +/** + * A `paths:` glob of the form `dir/**` selects a file under `dir`. Only that + * form. A narrower glob (`dir/src/**`) would leave part of the tree + * unwatched. GitHub's full glob grammar is not needed to read this one filter. + */ +const coversTree = (pattern, file) => + pattern.endsWith('/**') && file.startsWith(pattern.slice(0, -2)) + +/** + * The files the workspace's crates compile through a `#[path = "..."]` + * module. Cargo.lock does not record them, so they are read from every `.rs` + * file under `crates/`. A path is resolved from the directory of the file + * that names it, which is Rust's rule outside an inline `mod { }` block. + */ +function pathModuleFiles() { + const files = [] + const walk = (dir) => { + for (const entry of readdirSync(join(REPO_ROOT, dir), { + withFileTypes: true, + })) { + const rel = `${dir}/${entry.name}` + if (entry.isDirectory()) walk(rel) + else if (entry.name.endsWith('.rs')) { + const attribute = /#\[\s*path\s*=\s*"([^"]+)"\s*\]/g + for (const match of read(rel).matchAll(attribute)) { + files.push(posix.normalize(`${dir}/${match[1]}`)) + } + } + } + } + walk(`${PYTHON_ROOT}/crates`) + return files.sort() +} + +describe('tests-python.yml', () => { + it('runs on pull requests touching every tree the Python workspace compiles', () => { + const on = triggers(readWorkflow(WORKFLOW)) + const paths = on.pull_request?.paths ?? [] + const wanted = [ + PYTHON_ROOT, + ...pathDependencyTrees(), + // The issue names the EQL codegen. eql-codegen generates the marker + // names the SDK ships from the eql-domains catalog (#1385). A change to + // either must rerun the suite that reads the generated names. + 'packages/eql/crates/eql-codegen', + 'packages/eql/crates/eql-domains', + ] + const missing = wanted.filter( + (dir) => !filterCovers(paths, `${dir}/x`, coversTree), + ) + expect( + missing, + `pull_request.paths does not select these trees, which the Python workspace compiles or generates from:\n${missing.join('\n')}`, + ).toEqual([]) + }) + + it('finds the stack crates as path dependencies, so the filter check is not empty', () => { + // A lockfile parser that finds nothing would leave the check above with + // no tree to look for, and it would pass. + expect(pathDependencyTrees()).toEqual( + expect.arrayContaining(['packages/stack-encrypt', 'packages/stack-kms']), + ) + }) + + it('runs on pull requests touching a file the core crate compiles by #[path]', () => { + const paths = triggers(readWorkflow(WORKFLOW)).pull_request?.paths ?? [] + const files = pathModuleFiles() + expect(files).toContain('languages/golang/encrypt/guest/src/targets.rs') + const exact = (pattern, file) => pattern === file + const missing = files.filter( + (file) => + !filterCovers(paths, file, (p, f) => exact(p, f) || coversTree(p, f)), + ) + expect( + missing, + `pull_request.paths does not select these files, which crates/core compiles by #[path]:\n${missing.join('\n')}`, + ).toEqual([]) + }) + + it('runs the pure-Python suite on the oldest supported Python and the one mise.toml names', () => { + // The floor is `requires-python` in pyproject.toml; the local version is the + // interpreter `mise run test` links locally. A wheel tagged abi3-py39 is a + // claim about the floor, and only a run on the floor tests it. + const floor = /^requires-python = ">=(\d+\.\d+)"/m.exec( + read(`${PYTHON_ROOT}/pyproject.toml`), + )?.[1] + const local = /^python = "(\d+\.\d+)"/m.exec( + read(`${PYTHON_ROOT}/mise.toml`), + )?.[1] + expect(floor, 'pyproject.toml requires-python floor').toBeTruthy() + expect(local, 'languages/python/mise.toml python version').toBeTruthy() + + const wf = readWorkflow(WORKFLOW) + const matrices = Object.entries(wf.jobs) + .map(([id, job]) => [id, job.strategy?.matrix?.python]) + .filter(([, versions]) => Array.isArray(versions)) + expect(matrices.length, 'one job carries a `python:` matrix').toBe(1) + const [[id, versions]] = matrices + // Strings, not floats: YAML reads a bare `3.10` as the number 3.1. + expect( + versions.map(String).sort(), + `jobs.${id}.strategy.matrix.python must be exactly the floor and the local version`, + ).toEqual([floor, local].sort()) + for (const v of versions) { + expect(typeof v, `matrix.python entry ${v} must be a quoted string`).toBe( + 'string', + ) + } + }) + + it('builds one wheel per platform the FFI ships for, by the same Rust triples', () => { + // Each platform package carries `neon.rust`, the triple its binary is + // built for. The issue names "the six platforms the FFI already ships + // for". So the wheel matrix is held to that list, not to a copy of it. A + // seventh platform package, or a moved triple, fails here. The count six + // is written here and nowhere else: the workflow derives its own count + // from the platform list in `wheels-summary`. + const platformsDir = 'languages/typescript/packages/protect-ffi/platforms' + const ffi = readdirSync(join(REPO_ROOT, platformsDir)) + .map((name) => { + const pkg = JSON.parse(read(`${platformsDir}/${name}/package.json`)) + return { platform: name, target: pkg.neon?.rust } + }) + .filter((p) => p.target) + .sort((a, b) => a.platform.localeCompare(b.platform)) + expect(ffi.length, 'protect-ffi platform packages').toBe(6) + + const wf = readWorkflow(WORKFLOW) + const wheelJobs = Object.entries(wf.jobs).filter( + ([, job]) => + Array.isArray(job.strategy?.matrix?.include) && + job.strategy.matrix.include.some((leg) => leg.target), + ) + expect(wheelJobs.length, 'one job carries a matrix of `target` legs').toBe( + 1, + ) + const [[id, job]] = wheelJobs + const legs = job.strategy.matrix.include + .map(({ platform, target }) => ({ platform, target })) + .sort((a, b) => a.platform.localeCompare(b.platform)) + expect( + legs, + `jobs.${id}.strategy.matrix.include (platform, target)`, + ).toEqual(ffi) + }) + + it('restores a Rust cache in every job that compiles Rust', () => { + // The acceptance criterion, and the repo-wide rule eql-suite-ci.test.mjs + // holds the EQL workflows to. A job compiles Rust when a step runs cargo + // directly or through maturin-action. A mise task of the Python workspace + // counts too: every one of them builds. + const wf = readWorkflow(WORKFLOW) + const compiles = (step) => + /\bcargo\b|\bmise run\b/.test(step.run ?? '') || + /^PyO3\/maturin-action@/.test(step.uses ?? '') + // The scan must find the jobs that compile, or the check below passes + // over none: a wheel step in another form would drop out of it. + const compiling = Object.entries(wf.jobs) + .filter(([, job]) => job.steps.some(compiles)) + .map(([id]) => id) + expect(compiling.sort()).toEqual(['live', 'python', 'wheels']) + const missing = Object.entries(wf.jobs) + .filter(([, job]) => job.steps.some(compiles)) + .filter( + ([, job]) => + !job.steps.some((step) => + /^Swatinem\/rust-cache@/.test(step.uses ?? ''), + ), + ) + .map(([id]) => id) + expect( + missing, + 'jobs that compile Rust with no Swatinem/rust-cache step', + ).toEqual([]) + }) + + it('builds the deterministic test extension with the features of the normal build', () => { + // `--features` replaces pyproject.toml's list. A feature added there and + // not here would leave tests_deterministic/ testing another extension. + const pyproject = read(`${PYTHON_ROOT}/pyproject.toml`) + const declared = pyproject.match(/^features = \[([^\]]*)\]/m) + expect( + declared, + 'pyproject.toml has a [tool.maturin] features list', + ).not.toBeNull() + const normal = [...declared[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]) + // The test:deterministic task's own body: test:cargo names --features + // too, so the first match in the file depends on the order of tasks. + const body = read(`${PYTHON_ROOT}/mise.toml`).match( + /^\[tasks\."test:deterministic"\]\n([\s\S]*?)(?=^\[|(?![\s\S]))/m, + ) + expect(body, 'mise.toml has a test:deterministic task').not.toBeNull() + const task = body[1].match(/--features ([^\s"]+)/) + expect(task, 'test:deterministic passes --features').not.toBeNull() + expect(task[1].split(',').sort()).toEqual( + [...normal, 'deterministic-kms'].sort(), + ) + }) + + it('builds the wheels with the maturin version uv.lock locks', () => { + // The live job and mise run build use uv.lock's maturin; the wheels job + // pins its own. Dependabot updates the lock alone, so the two can part. + const lock = read(`${PYTHON_ROOT}/uv.lock`) + const locked = lock.match(/^name = "maturin"\nversion = "([^"]+)"/m) + expect(locked, 'uv.lock locks maturin').not.toBeNull() + const pins = [...read(WORKFLOW).matchAll(/maturin-version: v?(\S+)/g)].map( + (m) => m[1], + ) + expect(pins.length, 'maturin-version pins in the workflow').toBeGreaterThan( + 0, + ) + expect(new Set(pins), "every maturin-version equals uv.lock's").toEqual( + new Set([locked[1]]), + ) + }) + + it('runs the parity test that refuses a hand edit to _eql.py', () => { + // The one check that fails when someone edits the generated + // `_eql.py` by hand: test-eql.yml does not start on a change under + // languages/python. + const wf = readWorkflow(WORKFLOW) + const runs = Object.values(wf.jobs) + .flatMap((job) => job.steps) + .map((step) => step.run ?? '') + .filter((run) => + [ + 'cargo test', + '--locked', + '-p eql-codegen', + '--test py_eql_parity', + ].every((part) => run.includes(part)), + ) + expect( + runs, + "steps that run eql-codegen's py_eql_parity test", + ).toHaveLength(1) + }) + + it('builds from uv.lock, with no tool resolved outside it', () => { + // `uv sync --locked` refuses a lock that does not match pyproject.toml; + // without it, uv would update the lock and the build would pass. Each + // `uv pip install` must take nothing from the index: `--no-deps` for + // the package's dependencies, `--no-build-isolation` for maturin. + expect(existsSync(join(REPO_ROOT, PYTHON_ROOT, 'uv.lock'))).toBe(true) + const miseToml = read(`${PYTHON_ROOT}/mise.toml`) + const build = miseToml.match( + /^\[tasks\.build\]\n([\s\S]*?)(?=^\[|(?![\s\S]))/m, + ) + expect(build, 'mise.toml has a [tasks.build]').not.toBeNull() + const lines = build[1] + .split('\n') + .filter((line) => !line.trim().startsWith('#')) + const syncs = lines.filter((line) => /\buv sync\b/.test(line)) + expect(syncs, 'uv sync lines in the build task').toHaveLength(1) + expect(syncs[0]).toContain('--locked') + expect(syncs[0]).toContain('--group build') + const installs = lines.filter((line) => /\buv pip install\b/.test(line)) + expect( + installs.length, + 'uv pip install lines in the build task', + ).toBeGreaterThan(0) + for (const line of installs) { + expect(line).toContain('--no-deps') + expect(line).toContain('--no-build-isolation') + } + }) + + it('runs every task of the Python workspace, and the two cargo lints, from that folder', () => { + // "A check nothing invokes reads exactly like a check that passes" + // (crates-ci.test.mjs). That file scans the root mise.toml and the crate + // task files. It does not read languages/python/mise.toml, where the + // Python tasks live. So this is their caller check. Every task defined + // there is run by a step whose working directory is the workspace, except + // one another task depends on. fmt and clippy are plain cargo commands, + // not tasks, so they are named here. + const miseToml = read(`${PYTHON_ROOT}/mise.toml`) + const tasks = [ + ...miseToml.matchAll(/^\[tasks\.(?:"([^"]+)"|([\w:-]+))\]/gm), + ].map((m) => m[1] ?? m[2]) + const dependedOn = new Set( + [...miseToml.matchAll(/^depends = \[([^\]]*)\]/gm)].flatMap((m) => + [...m[1].matchAll(/"([^"]+)"/g)].map((d) => d[1]), + ), + ) + expect(tasks, 'tasks read from languages/python/mise.toml').toContain( + 'test', + ) + + const wf = readWorkflow(WORKFLOW) + const runs = Object.values(wf.jobs).flatMap((job) => + job.steps + .filter((step) => runWorkingDirectory(wf, job, step) === PYTHON_ROOT) + .map((step) => step.run ?? ''), + ) + const invoked = (command) => + runs.some((run) => + new RegExp(`(^|[\\s;&|])${command}(\\s|$)`, 'm').test(run), + ) + + // A local shortcut CI runs in parts. `test:live` builds, then runs + // `test:live:run`. The live job runs the builds and `test:live:run` as + // separate steps, so that no build runs with the CS_* credentials set. + const localOnly = { 'test:live': 'mise run test:live:run' } + for (const [task, runs] of Object.entries(localOnly)) { + const body = miseToml.match( + new RegExp( + `^\\[tasks\\."${task}"\\]\\n([\\s\\S]*?)(?=^\\[|(?![\\s\\S]))`, + 'm', + ), + ) + expect(body, `${task} is still a task`).not.toBeNull() + expect(body[1], `${task} still runs ${runs}`).toContain(runs) + } + + const wanted = [ + ...tasks + .filter((t) => !dependedOn.has(t) && !(t in localOnly)) + .map((t) => `mise run ${t}`), + 'cargo fmt --all --check', + 'cargo clippy', + ] + const missing = wanted.filter((command) => !invoked(command)) + expect( + missing, + `no step with working-directory ${PYTHON_ROOT} runs these:\n${missing.join('\n')}`, + ).toEqual([]) + }) + + it('lists in the summary job the same platforms the wheel matrix builds', () => { + // `wheels-summary` cannot see the matrix, so it carries the list a second + // time. This holds the copy to the original. + const wf = readWorkflow(WORKFLOW) + const matrix = wf.jobs.wheels.strategy.matrix.include.map( + (leg) => leg.platform, + ) + const summary = wf.jobs['wheels-summary'].steps + .map((s) => s.run ?? '') + .join('\n') + const listed = /^\s*platforms="([^"]+)"/m.exec(summary)?.[1]?.split(/\s+/) + expect(listed, 'a `platforms="…"` line in wheels-summary').toBeTruthy() + expect([...listed].sort()).toEqual([...matrix].sort()) + }) + + it('hands the engine commit to every container build, under the name build.rs reads', () => { + // maturin-action runs the Linux legs in a manylinux container, as root, + // on a bind mount the runner user owns. There the core crate's build + // script cannot read the commit from git. The first run of this workflow + // failed on all three container legs with its "no engine commit" panic. + // The script reads one override variable instead. The name comes from + // build.rs, so a rename there fails here. maturin-action forwards only + // the variables on its own prefix allowlist, so the step must name this + // one in `docker-options` as well as set it. + const override = /^const ENV_OVERRIDE: &str = "([A-Z0-9_]+)";/m.exec( + read(`${PYTHON_ROOT}/crates/core/build.rs`), + )?.[1] + expect(override, 'ENV_OVERRIDE in crates/core/build.rs').toBeTruthy() + + const wf = readWorkflow(WORKFLOW) + const steps = Object.values(wf.jobs).flatMap((job) => + job.steps.filter((step) => + /^PyO3\/maturin-action@/.test(step.uses ?? ''), + ), + ) + expect(steps.length, 'maturin-action steps').toBeGreaterThan(0) + for (const step of steps) { + expect( + step.env?.[override], + `the maturin-action step must set ${override}`, + ).toBeTruthy() + expect( + String(step.with?.['docker-options'] ?? '').split(/\s+/), + `docker-options must forward ${override} into the container`, + ).toEqual(expect.arrayContaining(['-e', override])) + } + }) + + it('gives a CipherStash credential to the live job only, which skips on a fork', () => { + // A credentialed job is one ffi-binding-step-order.test.mjs holds to the + // secrets pre-flight. Only `live` needs one (#1389). A fork's pull + // request has no secrets, so the job skips there rather than fails. + const { jobs } = readWorkflow(WORKFLOW) + const credentialed = Object.entries(jobs) + .filter(([, job]) => /(vars|secrets)\.CS_/.test(JSON.stringify(job))) + .map(([id]) => id) + expect(credentialed).toEqual(['live']) + expect(jobs.live.if).toMatch(/head\.repo\.full_name == github\.repository/) + }) +}) diff --git a/scripts/__tests__/python-lock-parity.test.mjs b/scripts/__tests__/python-lock-parity.test.mjs new file mode 100644 index 0000000000..97eb632542 --- /dev/null +++ b/scripts/__tests__/python-lock-parity.test.mjs @@ -0,0 +1,83 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { lockedPackages } from './lib/cargo-lock.mjs' +import { REPO_ROOT } from './lib/repo-root.mjs' + +/** + * The Python SDK's workspace and the root workspace must resolve the same + * crates that decide what a stored value is: the encryption, the order + * terms, and the key protocol. + * + * `languages/python` is its own Cargo workspace, with its own Cargo.lock. Its + * engine builds the stack-* crates by path, but each lock picks its own + * versions of the crates from crates.io. The root workspace's tests run the + * stack crates against the root lock. If the Python lock resolves another + * version of one of these crates, the Python engine runs code those tests + * never ran, and it can write values the other SDKs do not read the same way. + * + * Each crate is compared by version, source and checksum, as + * `cipherstash-client-parity.test.mjs` compares its crates. Other shared + * crates, such as chrono, may differ. + * + * The fix for a failure is to seed the Python lock from the root one again, + * as AGENTS.md describes. + * + * Node builtins only: no cargo on `pnpm run test:scripts` (see AGENTS.md). + */ + +const ROOT_LOCK = 'Cargo.lock' +const PYTHON_LOCK = 'languages/python/Cargo.lock' + +const read = (file) => readFileSync(join(REPO_ROOT, file), 'utf8') +const root = read(ROOT_LOCK) +const python = read(PYTHON_LOCK) + +/** Every package name a lock records. */ +const names = (lock) => + new Set([...lock.matchAll(/^name = "([^"]+)"$/gm)].map((m) => m[1])) + +/** + * The crates that decide what a stored value is. Every vitaminc crate is + * one: vitaminc is the encryption and the order terms. The rest are named: + * recipher, and the two order schemes, which the root Cargo.toml pins with + * `=`; cts-common and zerokms-protocol, the key protocol. + */ +const VALUE_CRATES = [ + ...[...names(root)].filter((name) => name.startsWith('vitaminc')).sort(), + 'recipher', + 'ore-rs', + 'clww-ore', + 'cts-common', + 'zerokms-protocol', +] + +const RESEED = + 'Seed it from the root lock again: copy Cargo.lock over ' + + 'languages/python/Cargo.lock, and run `cargo metadata` in languages/python. ' + + 'If cargo moves this crate to a newer version, move it back with ' + + '`cargo update -p @ --precise `.' + +describe('the Python lock and the root lock', () => { + it('both name every crate the comparison checks', () => { + // The floor: a crate that leaves either lock would make its comparison + // below compare two empty lists, and pass. + expect( + VALUE_CRATES.filter((name) => name.startsWith('vitaminc')).length, + ).toBeGreaterThan(0) + const missing = VALUE_CRATES.filter( + (name) => !names(root).has(name) || !names(python).has(name), + ) + expect(missing, 'crates missing from one of the two locks').toEqual([]) + }) + + it.each(VALUE_CRATES)('resolve the same %s', (name) => { + const inRoot = lockedPackages(root, name) + const inPython = lockedPackages(python, name) + expect( + inPython, + `${PYTHON_LOCK} resolves ${name} ${JSON.stringify(inPython)}, and ` + + `${ROOT_LOCK} resolves ${JSON.stringify(inRoot)}. ${RESEED}`, + ).toEqual(inRoot) + }) +}) diff --git a/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs b/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs index 3b02b86e77..e0f7ebd14b 100644 --- a/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs +++ b/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs @@ -5,7 +5,7 @@ import { UnsupportedExpression, unwrap, } from './lib/expressions.mjs' -import { readWorkflow, workflowFiles } from './lib/workflows.mjs' +import { readWorkflow, triggers, workflowFiles } from './lib/workflows.mjs' /** * A workflow that declares `workflow_dispatch:` must actually run when someone @@ -128,6 +128,8 @@ const EXPECTED_FORK_GUARDED_JOBS = [ '.github/workflows/prisma-next-e2e.yml / e2e', '.github/workflows/test-eql.yml / build-archive', '.github/workflows/test-eql.yml / e2e', + // The CS_* secrets, which forks are not given (#1389). + '.github/workflows/tests-python.yml / live', ] /** The context path every fork guard turns on. */ @@ -299,16 +301,6 @@ const EVENT_OF = { // Discovery // --------------------------------------------------------------------------- -/** - * `on:` parses as the boolean `true` under YAML 1.1 (the "Norway problem"), - * hence the two keys. `workflow_dispatch:` with no value parses as null, so - * membership — not truthiness — is what says the trigger is declared. - */ -function triggers(wf) { - const on = wf?.on ?? wf?.[true] - return on && typeof on === 'object' ? on : {} -} - function declaresDispatch(wf) { return 'workflow_dispatch' in triggers(wf) } diff --git a/scripts/__tests__/workflow-paths-filter-parity.test.mjs b/scripts/__tests__/workflow-paths-filter-parity.test.mjs index 5e719237c9..83c9ad5e23 100644 --- a/scripts/__tests__/workflow-paths-filter-parity.test.mjs +++ b/scripts/__tests__/workflow-paths-filter-parity.test.mjs @@ -122,6 +122,14 @@ const EXPECTED_ASYMMETRIES = new Map([ // unchecked — is unreachable when `push` is unfiltered. 'push is deliberately unfiltered (branches: [main]), so main runs a superset of what PRs run', ], + [ + '.github/workflows/tests-python.yml', + // The same shape as tests-rust.yml: `push:` is `branches: [main]` with no + // `paths:`, as #1383 asks ("on every push to main"), and only pull + // requests are filtered. The pull_request list is held to the Python + // workspace's Cargo.lock by python-ci.test.mjs, which is the second copy. + 'push is deliberately unfiltered (branches: [main]), so main runs a superset of what PRs run', + ], [ '.github/workflows/tests.yml', // `push: branches: [main]` has no `paths:`, so main runs everything and only diff --git a/scripts/__tests__/workflows-lib.test.mjs b/scripts/__tests__/workflows-lib.test.mjs new file mode 100644 index 0000000000..691209edfa --- /dev/null +++ b/scripts/__tests__/workflows-lib.test.mjs @@ -0,0 +1,25 @@ +import yaml from 'js-yaml' +import { describe, expect, it } from 'vitest' +import { triggers } from './lib/workflows.mjs' + +// Seven workflow guards read their triggers through this helper. A change +// that drops a trigger would make each of them check fewer workflows, and +// pass. +describe('triggers()', () => { + it('reads `on:` under the YAML 1.1 `true` key and under a quoted key', () => { + expect(triggers(yaml.load('on:\n push: {}'))).toHaveProperty('push') + expect(triggers(yaml.load("'on':\n push: {}"))).toHaveProperty('push') + }) + + it('keeps a trigger declared with no value', () => { + expect( + 'workflow_dispatch' in triggers(yaml.load('on:\n workflow_dispatch:')), + ).toBe(true) + }) + + it('gives {} for a missing or non-object `on:`', () => { + expect(triggers(yaml.load('name: x'))).toEqual({}) + expect(triggers(yaml.load('on: push'))).toEqual({}) + expect(triggers(undefined)).toEqual({}) + }) +}) diff --git a/skills/stash-supply-chain-security/SKILL.md b/skills/stash-supply-chain-security/SKILL.md index a5f63d371b..f08f3e05ce 100644 --- a/skills/stash-supply-chain-security/SKILL.md +++ b/skills/stash-supply-chain-security/SKILL.md @@ -65,7 +65,7 @@ is checked by the workflow's own test, not by `supply-chain.e2e.test.ts`.) ### 5. Cooldown'd auto-updates — practice #6 -Dependabot opens grouped, cooldown'd PRs (7 days minor/patch) for `npm`, `cargo`, `gomod`, `pip` (the hashed requirements file for a Python tool CI runs), `github-actions` and `docker` (the digest of the Alpine image the musl binaries are built in). Major bumps are not proposed at all — every entry ignores `version-update:semver-major`, so majors are reviewed and applied by hand. +Dependabot opens grouped, cooldown'd PRs (7 days minor/patch) for `npm`, `cargo`, `gomod`, `pip` (the hashed requirements file for a Python tool CI runs), `uv` (the Python SDK's test lockfile), `github-actions` and `docker` (the digest of the Alpine image the musl binaries are built in). Major bumps are not proposed at all — every entry ignores `version-update:semver-major`, so majors are reviewed and applied by hand. There is deliberately **no `semver-major-days` cooldown** on any entry. It would delay major *version update* PRs, which the `ignore` above means Dependabot never opens, and cooldown does not reach the security path either ("the cooldown option is only available for version updates, not security updates"). Don't add one back as a safety net for the day the `ignore` is dropped — dead config reads as policy, and the test below fails on the pair.